fitzroy 1.8.0 → 2.0.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 (5) hide show
  1. package/README.md +45 -17
  2. package/dist/cli.js +4568 -4235
  3. package/dist/index.d.ts +177 -120
  4. package/dist/index.js +2076 -1744
  5. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -20,12 +20,25 @@ var UnsupportedSourceError = class extends Error {
20
20
  }
21
21
  name = "UnsupportedSourceError";
22
22
  };
23
- function aflwUnsupportedError(source) {
24
- return new UnsupportedSourceError(
25
- `AFLW data is not available from ${source}. Use --source afl-api for AFLW data.`,
26
- source
27
- );
28
- }
23
+ var UnsupportedCompetitionError = class extends Error {
24
+ constructor(message, source, competition, suggestion) {
25
+ super(message);
26
+ this.source = source;
27
+ this.competition = competition;
28
+ this.suggestion = suggestion;
29
+ }
30
+ name = "UnsupportedCompetitionError";
31
+ };
32
+ var OutOfRangeError = class extends Error {
33
+ constructor(message, source, competition, season, suggestion) {
34
+ super(message);
35
+ this.source = source;
36
+ this.competition = competition;
37
+ this.season = season;
38
+ this.suggestion = suggestion;
39
+ }
40
+ name = "OutOfRangeError";
41
+ };
29
42
  var ValidationError = class extends Error {
30
43
  constructor(message, issues) {
31
44
  super(message);
@@ -41,142 +54,39 @@ function ok(data) {
41
54
  function err(error) {
42
55
  return { success: false, error };
43
56
  }
44
-
45
- // src/sources/footywire.ts
46
- import * as cheerio2 from "cheerio";
47
-
48
- // src/lib/date-utils.ts
49
- function parseDate(raw, defaultYear) {
50
- if (typeof raw === "number") {
51
- const date = new Date(raw * 1e3);
52
- return Number.isNaN(date.getTime()) ? null : date;
53
- }
54
- const trimmed = raw.trim();
55
- if (trimmed === "") return null;
56
- if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
57
- const stripped = trimmed.replace(/[Zz]$|[+-]\d{2}:\d{2}$/, "");
58
- const utc = stripped.includes("T") ? `${stripped}Z` : `${stripped}T00:00:00Z`;
59
- const date = new Date(utc);
60
- return Number.isNaN(date.getTime()) ? null : date;
61
- }
62
- const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
63
- const normalised = withoutDow.replace(/[-/]/g, " ");
64
- const fullMatch = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
65
- if (fullMatch) {
66
- const [, dayStr, monthStr, yearStr] = fullMatch;
67
- if (dayStr && monthStr && yearStr) {
68
- return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
69
- }
70
- }
71
- const mdyMatch = /^([A-Za-z]+)\s+(\d{1,2})\s+(\d{4})$/.exec(normalised);
72
- if (mdyMatch) {
73
- const [, monthStr, dayStr, yearStr] = mdyMatch;
74
- if (dayStr && monthStr && yearStr) {
75
- return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
76
- }
77
- }
78
- const shortMatch = /^(\d{1,2})\s+([A-Za-z]+)(?:\s+(\d{1,2}):(\d{2})(am|pm))?$/i.exec(normalised);
79
- if (shortMatch && defaultYear != null) {
80
- const [, dayStr, monthStr, hourStr, minStr, ampm] = shortMatch;
81
- if (!dayStr || !monthStr) return null;
82
- const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
83
- if (monthIndex === void 0) return null;
84
- const day = Number.parseInt(dayStr, 10);
85
- if (!hourStr || !minStr || !ampm) {
86
- return buildUtcDate(defaultYear, monthStr, day);
57
+ var Result = {
58
+ /** Transform the success value of a Result. Errors pass through unchanged. */
59
+ map(result, fn) {
60
+ return result.success ? ok(fn(result.data)) : result;
61
+ },
62
+ /** Chain a Result-returning function. Errors short-circuit. */
63
+ flatMap(result, fn) {
64
+ return result.success ? fn(result.data) : result;
65
+ },
66
+ /**
67
+ * Chain an async Result-returning function. Errors short-circuit without
68
+ * invoking `fn`.
69
+ */
70
+ async flatMapAsync(result, fn) {
71
+ return result.success ? fn(result.data) : result;
72
+ },
73
+ /**
74
+ * Collect an array of Results into a single Result of an array. Returns
75
+ * the first error encountered, or `ok` of all successful values.
76
+ */
77
+ all(results) {
78
+ const data = [];
79
+ for (const r of results) {
80
+ if (!r.success) return r;
81
+ data.push(r.data);
87
82
  }
88
- let hours = Number.parseInt(hourStr, 10);
89
- const minutes = Number.parseInt(minStr, 10);
90
- if (ampm.toLowerCase() === "pm" && hours < 12) hours += 12;
91
- if (ampm.toLowerCase() === "am" && hours === 12) hours = 0;
92
- const date = melbourneLocalToUtc(defaultYear, monthIndex, day, hours, minutes);
93
- return Number.isNaN(date.getTime()) ? null : date;
94
- }
95
- return null;
96
- }
97
- function parseAflApiDate(iso) {
98
- return parseDate(iso);
99
- }
100
- function parseAflApiMatchTime(iso) {
101
- return parseDate(iso);
102
- }
103
- function parseFootyWireDate(dateStr, defaultYear) {
104
- return parseDate(dateStr, defaultYear);
105
- }
106
- function parseAflTablesDate(dateStr) {
107
- return parseDate(dateStr);
108
- }
109
- function toAestString(date) {
110
- const formatter = new Intl.DateTimeFormat("en-AU", {
111
- timeZone: "Australia/Melbourne",
112
- weekday: "short",
113
- day: "numeric",
114
- month: "short",
115
- year: "numeric",
116
- hour: "numeric",
117
- minute: "2-digit",
118
- hour12: true,
119
- timeZoneName: "short"
120
- });
121
- return formatter.format(date);
122
- }
123
- function resolveDefaultSeason(competition = "AFLM") {
124
- const year = (/* @__PURE__ */ new Date()).getFullYear();
125
- return competition === "AFLW" ? year - 1 : year;
126
- }
127
- var MONTH_ABBREV_TO_INDEX = /* @__PURE__ */ new Map([
128
- ["jan", 0],
129
- ["feb", 1],
130
- ["mar", 2],
131
- ["apr", 3],
132
- ["may", 4],
133
- ["jun", 5],
134
- ["jul", 6],
135
- ["aug", 7],
136
- ["sep", 8],
137
- ["oct", 9],
138
- ["nov", 10],
139
- ["dec", 11],
140
- ["january", 0],
141
- ["february", 1],
142
- ["march", 2],
143
- ["april", 3],
144
- ["june", 5],
145
- ["july", 6],
146
- ["august", 7],
147
- ["september", 8],
148
- ["october", 9],
149
- ["november", 10],
150
- ["december", 11]
151
- ]);
152
- function melbourneLocalToUtc(year, monthIndex, day, hours, minutes) {
153
- const aestGuess = new Date(Date.UTC(year, monthIndex, day, hours - 10, minutes));
154
- const parts = new Intl.DateTimeFormat("en-AU", {
155
- timeZone: "Australia/Melbourne",
156
- day: "2-digit",
157
- hour: "2-digit",
158
- hour12: false
159
- }).formatToParts(aestGuess);
160
- const getNum = (type) => Number(parts.find((p) => p.type === type)?.value);
161
- if (getNum("day") === day && getNum("hour") === hours % 24) {
162
- return aestGuess;
163
- }
164
- return new Date(Date.UTC(year, monthIndex, day, hours - 11, minutes));
165
- }
166
- function buildUtcDate(year, monthStr, day) {
167
- const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
168
- if (monthIndex === void 0) {
169
- return null;
170
- }
171
- const date = new Date(Date.UTC(year, monthIndex, day));
172
- if (Number.isNaN(date.getTime())) {
173
- return null;
174
- }
175
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
176
- return null;
83
+ return ok(data);
84
+ },
85
+ /** Transform the error value of a Result. Successes pass through unchanged. */
86
+ mapErr(result, fn) {
87
+ return result.success ? result : err(fn(result.error));
177
88
  }
178
- return date;
179
- }
89
+ };
180
90
 
181
91
  // src/lib/team-mapping.ts
182
92
  var TEAM_ALIASES = [
@@ -273,85 +183,374 @@ var AFL_API_TEAM_IDS = /* @__PURE__ */ new Map([
273
183
  ["CD_T140", "Western Bulldogs"]
274
184
  ]);
275
185
 
276
- // src/lib/venue-mapping.ts
277
- var VENUE_ALIASES = [
278
- ["MCG", "M.C.G.", "Melbourne Cricket Ground"],
279
- ["SCG", "S.C.G.", "Sydney Cricket Ground"],
280
- ["Marvel Stadium", "Docklands", "Etihad Stadium", "Telstra Dome", "Colonial Stadium"],
281
- ["Kardinia Park", "GMHBA Stadium", "Simonds Stadium", "Skilled Stadium"],
282
- ["Gabba", "The Gabba", "Brisbane Cricket Ground"],
283
- [
284
- "Sydney Showground",
285
- "ENGIE Stadium",
286
- "GIANTS Stadium",
287
- "Showground Stadium",
288
- "Sydney Showground Stadium"
289
- ],
290
- ["Accor Stadium", "Stadium Australia", "ANZ Stadium", "Homebush"],
291
- ["Carrara", "People First Stadium", "Heritage Bank Stadium", "Metricon Stadium"],
292
- ["Perth Stadium", "Optus Stadium"],
293
- ["Adelaide Oval"],
294
- ["Manuka Oval", "Corroboree Group Oval Manuka"],
295
- ["Blundstone Arena", "Bellerive Oval"],
296
- ["UTAS Stadium", "York Park", "University of Tasmania Stadium", "Aurora Stadium"],
297
- ["TIO Stadium", "Marrara Oval"],
298
- ["Traeger Park", "TIO Traeger Park"],
299
- ["Mars Stadium", "Eureka Stadium"],
300
- ["Cazalys Stadium", "Cazaly's Stadium"],
301
- ["Jiangwan Stadium"],
302
- ["Riverway Stadium"],
303
- ["Norwood Oval"],
304
- ["Subiaco Oval", "Subiaco"],
305
- ["Football Park", "AAMI Stadium"],
306
- ["Princes Park", "Ikon Park"],
307
- ["Blacktown International Sportspark"],
308
- ["Barossa Park", "Barossa Oval", "Adelaide Hills"],
309
- ["Ninja Stadium", "Summit Sports Park"]
310
- ];
311
- var VENUE_ALIAS_MAP = (() => {
312
- const map = /* @__PURE__ */ new Map();
313
- for (const [canonical, ...aliases] of VENUE_ALIASES) {
314
- map.set(canonical.toLowerCase(), canonical);
315
- for (const alias of aliases) {
316
- map.set(alias.toLowerCase(), canonical);
186
+ // src/sources/afl-coaches.ts
187
+ import * as cheerio from "cheerio";
188
+ var AflCoachesClient = class {
189
+ fetchFn;
190
+ constructor(options) {
191
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch.bind(globalThis);
192
+ }
193
+ /**
194
+ * Fetch the HTML content of an AFLCA page.
195
+ */
196
+ async fetchHtml(url) {
197
+ try {
198
+ const response = await this.fetchFn(url, {
199
+ headers: {
200
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
201
+ }
202
+ });
203
+ if (!response.ok) {
204
+ return err(
205
+ new ScrapeError(`AFL Coaches request failed: ${response.status} (${url})`, "afl-coaches")
206
+ );
207
+ }
208
+ const html = await response.text();
209
+ return ok(html);
210
+ } catch (cause) {
211
+ return err(
212
+ new ScrapeError(
213
+ `AFL Coaches request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
214
+ "afl-coaches"
215
+ )
216
+ );
317
217
  }
318
218
  }
319
- return map;
320
- })();
321
- function normaliseVenueName(raw) {
322
- const trimmed = raw.trim();
323
- return VENUE_ALIAS_MAP.get(trimmed.toLowerCase()) ?? trimmed;
324
- }
325
-
326
- // src/transforms/footywire-player-stats.ts
327
- import * as cheerio from "cheerio";
328
-
329
- // src/lib/parse-utils.ts
330
- function safeInt(text) {
331
- const cleaned = text.replace(/[^0-9-]/g, "").trim();
332
- if (!cleaned) return null;
333
- const n = Number.parseInt(cleaned, 10);
334
- return Number.isNaN(n) ? null : n;
335
- }
336
- function parseIntOr0(text) {
337
- const n = Number.parseInt(text.replace(/[^0-9-]/g, ""), 10);
338
- return Number.isNaN(n) ? 0 : n;
339
- }
340
- function parseFloatOr0(text) {
341
- const n = Number.parseFloat(text.replace(/[^0-9.-]/g, ""));
342
- return Number.isNaN(n) ? 0 : n;
343
- }
344
-
345
- // src/transforms/footywire-player-stats.ts
346
- var BASIC_COLS = [
347
- "Player",
348
- "K",
349
- "HB",
350
- "D",
351
- "M",
352
- "G",
353
- "B",
354
- "T",
219
+ /**
220
+ * Build the AFLCA leaderboard URL for a given season, round, and competition.
221
+ *
222
+ * Mirrors the R package URL construction from `helper-aflcoaches.R`.
223
+ *
224
+ * @param season - Season year (e.g. 2024).
225
+ * @param roundNumber - Round number.
226
+ * @param competition - "AFLM" or "AFLW".
227
+ * @param isFinals - Whether this is a finals round.
228
+ */
229
+ buildUrl(season, roundNumber, competition, isFinals) {
230
+ const linkBase = competition === "AFLW" ? "https://aflcoaches.com.au/awards/aflw-champion-player-of-the-year-award/leaderboard/" : isFinals ? "https://aflcoaches.com.au/awards/gary-ayres-award-best-finals-player/leaderboard/" : "https://aflcoaches.com.au/awards/the-aflca-champion-player-of-the-year-award/leaderboard/";
231
+ const compSuffix = competition === "AFLW" ? "02" : "01";
232
+ const secondPart = season >= 2023 ? season + 1 : season;
233
+ const roundPad = String(roundNumber).padStart(2, "0");
234
+ return `${linkBase}${season}/${secondPart}${compSuffix}${roundPad}`;
235
+ }
236
+ /**
237
+ * Scrape coaches votes for a single round.
238
+ *
239
+ * @param season - Season year.
240
+ * @param roundNumber - Round number.
241
+ * @param competition - "AFLM" or "AFLW".
242
+ * @param isFinals - Whether this is a finals round.
243
+ * @returns Array of coaches vote records for that round.
244
+ */
245
+ async scrapeRoundVotes(season, roundNumber, competition, isFinals) {
246
+ const url = this.buildUrl(season, roundNumber, competition, isFinals);
247
+ const htmlResult = await this.fetchHtml(url);
248
+ if (!htmlResult.success) {
249
+ return htmlResult;
250
+ }
251
+ try {
252
+ const votes = parseCoachesVotesHtml(htmlResult.data, season, roundNumber);
253
+ return ok(votes);
254
+ } catch (cause) {
255
+ return err(
256
+ new ScrapeError(
257
+ `Failed to parse coaches votes: ${cause instanceof Error ? cause.message : String(cause)}`,
258
+ "afl-coaches"
259
+ )
260
+ );
261
+ }
262
+ }
263
+ /**
264
+ * Fetch coaches votes for an entire season (all rounds).
265
+ *
266
+ * Iterates over rounds 1-30, skipping rounds that return errors (e.g. byes or
267
+ * rounds that haven't been played yet). Finals rounds (>= 24) use the finals URL.
268
+ *
269
+ * @param season - Season year.
270
+ * @param competition - "AFLM" or "AFLW".
271
+ * @returns Combined array of coaches votes for the season.
272
+ */
273
+ async fetchSeasonVotes(season, competition) {
274
+ const allVotes = [];
275
+ const maxRound = 30;
276
+ for (let round = 1; round <= maxRound; round++) {
277
+ const isFinals = round >= 24 && season >= 2018;
278
+ const result = await this.scrapeRoundVotes(season, round, competition, isFinals);
279
+ if (result.success && result.data.length > 0) {
280
+ allVotes.push(...result.data);
281
+ }
282
+ }
283
+ if (allVotes.length === 0) {
284
+ return err(new ScrapeError(`No coaches votes found for season ${season}`, "afl-coaches"));
285
+ }
286
+ return ok(allVotes);
287
+ }
288
+ };
289
+ function parseCoachesVotesHtml(html, season, roundNumber) {
290
+ const $ = cheerio.load(html);
291
+ const clubLogos = $(".pr-md-3.votes-by-match .club_logo");
292
+ const homeTeams = [];
293
+ const awayTeams = [];
294
+ clubLogos.each((i, el) => {
295
+ const title = $(el).attr("title") ?? "";
296
+ if (i % 2 === 0) {
297
+ homeTeams.push(title);
298
+ } else {
299
+ awayTeams.push(title);
300
+ }
301
+ });
302
+ const rawVotes = [];
303
+ $(".pr-md-3.votes-by-match .col-2").each((_i, el) => {
304
+ const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
305
+ rawVotes.push(text);
306
+ });
307
+ const rawPlayers = [];
308
+ $(".pr-md-3.votes-by-match .col-10").each((_i, el) => {
309
+ const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
310
+ rawPlayers.push(text);
311
+ });
312
+ const votes = [];
313
+ let matchIndex = 0;
314
+ for (let i = 0; i < rawPlayers.length; i++) {
315
+ const playerName = rawPlayers[i] ?? "";
316
+ const voteText = rawVotes[i] ?? "";
317
+ if (playerName === "Player (Club)" && voteText === "Votes") {
318
+ matchIndex++;
319
+ continue;
320
+ }
321
+ const homeTeam = homeTeams[matchIndex - 1];
322
+ const awayTeam = awayTeams[matchIndex - 1];
323
+ if (homeTeam == null || awayTeam == null) {
324
+ continue;
325
+ }
326
+ const voteCount = Number(voteText);
327
+ if (Number.isNaN(voteCount)) {
328
+ continue;
329
+ }
330
+ votes.push({
331
+ type: "coaches",
332
+ season,
333
+ round: roundNumber,
334
+ homeTeam,
335
+ awayTeam,
336
+ playerName,
337
+ votes: voteCount
338
+ });
339
+ }
340
+ return votes;
341
+ }
342
+
343
+ // src/sources/footywire.ts
344
+ import * as cheerio3 from "cheerio";
345
+
346
+ // src/lib/date-utils.ts
347
+ function parseDate(raw, defaultYear) {
348
+ if (typeof raw === "number") {
349
+ const date = new Date(raw * 1e3);
350
+ return Number.isNaN(date.getTime()) ? null : date;
351
+ }
352
+ const trimmed = raw.trim();
353
+ if (trimmed === "") return null;
354
+ if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
355
+ const stripped = trimmed.replace(/[Zz]$|[+-]\d{2}:\d{2}$/, "");
356
+ const utc = stripped.includes("T") ? `${stripped}Z` : `${stripped}T00:00:00Z`;
357
+ const date = new Date(utc);
358
+ return Number.isNaN(date.getTime()) ? null : date;
359
+ }
360
+ const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
361
+ const normalised = withoutDow.replace(/[-/]/g, " ");
362
+ const fullMatch = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
363
+ if (fullMatch) {
364
+ const [, dayStr, monthStr, yearStr] = fullMatch;
365
+ if (dayStr && monthStr && yearStr) {
366
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
367
+ }
368
+ }
369
+ const mdyMatch = /^([A-Za-z]+)\s+(\d{1,2})\s+(\d{4})$/.exec(normalised);
370
+ if (mdyMatch) {
371
+ const [, monthStr, dayStr, yearStr] = mdyMatch;
372
+ if (dayStr && monthStr && yearStr) {
373
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
374
+ }
375
+ }
376
+ const shortMatch = /^(\d{1,2})\s+([A-Za-z]+)(?:\s+(\d{1,2}):(\d{2})(am|pm))?$/i.exec(normalised);
377
+ if (shortMatch && defaultYear != null) {
378
+ const [, dayStr, monthStr, hourStr, minStr, ampm] = shortMatch;
379
+ if (!dayStr || !monthStr) return null;
380
+ const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
381
+ if (monthIndex === void 0) return null;
382
+ const day = Number.parseInt(dayStr, 10);
383
+ if (!hourStr || !minStr || !ampm) {
384
+ return buildUtcDate(defaultYear, monthStr, day);
385
+ }
386
+ let hours = Number.parseInt(hourStr, 10);
387
+ const minutes = Number.parseInt(minStr, 10);
388
+ if (ampm.toLowerCase() === "pm" && hours < 12) hours += 12;
389
+ if (ampm.toLowerCase() === "am" && hours === 12) hours = 0;
390
+ const date = melbourneLocalToUtc(defaultYear, monthIndex, day, hours, minutes);
391
+ return Number.isNaN(date.getTime()) ? null : date;
392
+ }
393
+ return null;
394
+ }
395
+ function parseAflApiDate(iso) {
396
+ return parseDate(iso);
397
+ }
398
+ function parseAflApiMatchTime(iso) {
399
+ return parseDate(iso);
400
+ }
401
+ function parseFootyWireDate(dateStr, defaultYear) {
402
+ return parseDate(dateStr, defaultYear);
403
+ }
404
+ function parseAflTablesDate(dateStr) {
405
+ return parseDate(dateStr);
406
+ }
407
+ function toAestString(date) {
408
+ const formatter = new Intl.DateTimeFormat("en-AU", {
409
+ timeZone: "Australia/Melbourne",
410
+ weekday: "short",
411
+ day: "numeric",
412
+ month: "short",
413
+ year: "numeric",
414
+ hour: "numeric",
415
+ minute: "2-digit",
416
+ hour12: true,
417
+ timeZoneName: "short"
418
+ });
419
+ return formatter.format(date);
420
+ }
421
+ var MONTH_ABBREV_TO_INDEX = /* @__PURE__ */ new Map([
422
+ ["jan", 0],
423
+ ["feb", 1],
424
+ ["mar", 2],
425
+ ["apr", 3],
426
+ ["may", 4],
427
+ ["jun", 5],
428
+ ["jul", 6],
429
+ ["aug", 7],
430
+ ["sep", 8],
431
+ ["oct", 9],
432
+ ["nov", 10],
433
+ ["dec", 11],
434
+ ["january", 0],
435
+ ["february", 1],
436
+ ["march", 2],
437
+ ["april", 3],
438
+ ["june", 5],
439
+ ["july", 6],
440
+ ["august", 7],
441
+ ["september", 8],
442
+ ["october", 9],
443
+ ["november", 10],
444
+ ["december", 11]
445
+ ]);
446
+ function melbourneLocalToUtc(year, monthIndex, day, hours, minutes) {
447
+ const aestGuess = new Date(Date.UTC(year, monthIndex, day, hours - 10, minutes));
448
+ const parts = new Intl.DateTimeFormat("en-AU", {
449
+ timeZone: "Australia/Melbourne",
450
+ day: "2-digit",
451
+ hour: "2-digit",
452
+ hour12: false
453
+ }).formatToParts(aestGuess);
454
+ const getNum = (type) => Number(parts.find((p) => p.type === type)?.value);
455
+ if (getNum("day") === day && getNum("hour") === hours % 24) {
456
+ return aestGuess;
457
+ }
458
+ return new Date(Date.UTC(year, monthIndex, day, hours - 11, minutes));
459
+ }
460
+ function buildUtcDate(year, monthStr, day) {
461
+ const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
462
+ if (monthIndex === void 0) {
463
+ return null;
464
+ }
465
+ const date = new Date(Date.UTC(year, monthIndex, day));
466
+ if (Number.isNaN(date.getTime())) {
467
+ return null;
468
+ }
469
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
470
+ return null;
471
+ }
472
+ return date;
473
+ }
474
+
475
+ // src/lib/venue-mapping.ts
476
+ var VENUE_ALIASES = [
477
+ ["MCG", "M.C.G.", "Melbourne Cricket Ground"],
478
+ ["SCG", "S.C.G.", "Sydney Cricket Ground"],
479
+ ["Marvel Stadium", "Docklands", "Etihad Stadium", "Telstra Dome", "Colonial Stadium"],
480
+ ["Kardinia Park", "GMHBA Stadium", "Simonds Stadium", "Skilled Stadium"],
481
+ ["Gabba", "The Gabba", "Brisbane Cricket Ground"],
482
+ [
483
+ "Sydney Showground",
484
+ "ENGIE Stadium",
485
+ "GIANTS Stadium",
486
+ "Showground Stadium",
487
+ "Sydney Showground Stadium"
488
+ ],
489
+ ["Accor Stadium", "Stadium Australia", "ANZ Stadium", "Homebush"],
490
+ ["Carrara", "People First Stadium", "Heritage Bank Stadium", "Metricon Stadium"],
491
+ ["Perth Stadium", "Optus Stadium"],
492
+ ["Adelaide Oval"],
493
+ ["Manuka Oval", "Corroboree Group Oval Manuka"],
494
+ ["Blundstone Arena", "Bellerive Oval"],
495
+ ["UTAS Stadium", "York Park", "University of Tasmania Stadium", "Aurora Stadium"],
496
+ ["TIO Stadium", "Marrara Oval"],
497
+ ["Traeger Park", "TIO Traeger Park"],
498
+ ["Mars Stadium", "Eureka Stadium"],
499
+ ["Cazalys Stadium", "Cazaly's Stadium"],
500
+ ["Jiangwan Stadium"],
501
+ ["Riverway Stadium"],
502
+ ["Norwood Oval"],
503
+ ["Subiaco Oval", "Subiaco"],
504
+ ["Football Park", "AAMI Stadium"],
505
+ ["Princes Park", "Ikon Park"],
506
+ ["Blacktown International Sportspark"],
507
+ ["Barossa Park", "Barossa Oval", "Adelaide Hills"],
508
+ ["Ninja Stadium", "Summit Sports Park"]
509
+ ];
510
+ var VENUE_ALIAS_MAP = (() => {
511
+ const map = /* @__PURE__ */ new Map();
512
+ for (const [canonical, ...aliases] of VENUE_ALIASES) {
513
+ map.set(canonical.toLowerCase(), canonical);
514
+ for (const alias of aliases) {
515
+ map.set(alias.toLowerCase(), canonical);
516
+ }
517
+ }
518
+ return map;
519
+ })();
520
+ function normaliseVenueName(raw) {
521
+ const trimmed = raw.trim();
522
+ return VENUE_ALIAS_MAP.get(trimmed.toLowerCase()) ?? trimmed;
523
+ }
524
+
525
+ // src/transforms/footywire-player-stats.ts
526
+ import * as cheerio2 from "cheerio";
527
+
528
+ // src/lib/parse-utils.ts
529
+ function safeInt(text) {
530
+ const cleaned = text.replace(/[^0-9-]/g, "").trim();
531
+ if (!cleaned) return null;
532
+ const n = Number.parseInt(cleaned, 10);
533
+ return Number.isNaN(n) ? null : n;
534
+ }
535
+ function parseIntOr0(text) {
536
+ const n = Number.parseInt(text.replace(/[^0-9-]/g, ""), 10);
537
+ return Number.isNaN(n) ? 0 : n;
538
+ }
539
+ function parseFloatOr0(text) {
540
+ const n = Number.parseFloat(text.replace(/[^0-9.-]/g, ""));
541
+ return Number.isNaN(n) ? 0 : n;
542
+ }
543
+
544
+ // src/transforms/footywire-player-stats.ts
545
+ var BASIC_COLS = [
546
+ "Player",
547
+ "K",
548
+ "HB",
549
+ "D",
550
+ "M",
551
+ "G",
552
+ "B",
553
+ "T",
355
554
  "HO",
356
555
  "GA",
357
556
  "I50",
@@ -387,7 +586,7 @@ function cleanPlayerName(raw) {
387
586
  return raw.replace(/[↗↙]/g, "").trim();
388
587
  }
389
588
  function parseStatsTable(html, expectedCols, rowParser) {
390
- const $ = cheerio.load(html);
589
+ const $ = cheerio2.load(html);
391
590
  const results = [];
392
591
  $("table").each((_i, table) => {
393
592
  const rows = $(table).find("tr");
@@ -644,8 +843,9 @@ function transformMatchItems(items, season, competition, source = "afl-api") {
644
843
  return items.map((item) => {
645
844
  const homeScore = item.score?.homeTeamScore;
646
845
  const awayScore = item.score?.awayTeamScore;
647
- const homePoints = homeScore?.matchScore.totalScore ?? 0;
648
- const awayPoints = awayScore?.matchScore.totalScore ?? 0;
846
+ const homePoints = homeScore ? homeScore.matchScore.totalScore : null;
847
+ const awayPoints = awayScore ? awayScore.matchScore.totalScore : null;
848
+ const margin = homePoints !== null && awayPoints !== null ? homePoints - awayPoints : null;
649
849
  return {
650
850
  matchId: item.match.matchId,
651
851
  season,
@@ -656,13 +856,13 @@ function transformMatchItems(items, season, competition, source = "afl-api") {
656
856
  venue: item.venue?.name ? normaliseVenueName(item.venue.name) : "",
657
857
  homeTeam: normaliseTeamName(item.match.homeTeam.name),
658
858
  awayTeam: normaliseTeamName(item.match.awayTeam.name),
659
- homeGoals: homeScore?.matchScore.goals ?? 0,
660
- homeBehinds: homeScore?.matchScore.behinds ?? 0,
859
+ homeGoals: homeScore ? homeScore.matchScore.goals : null,
860
+ homeBehinds: homeScore ? homeScore.matchScore.behinds : null,
661
861
  homePoints,
662
- awayGoals: awayScore?.matchScore.goals ?? 0,
663
- awayBehinds: awayScore?.matchScore.behinds ?? 0,
862
+ awayGoals: awayScore ? awayScore.matchScore.goals : null,
863
+ awayBehinds: awayScore ? awayScore.matchScore.behinds : null,
664
864
  awayPoints,
665
- margin: homePoints - awayPoints,
865
+ margin,
666
866
  q1Home: findPeriod(homeScore?.periodScore, 1),
667
867
  q2Home: findPeriod(homeScore?.periodScore, 2),
668
868
  q3Home: findPeriod(homeScore?.periodScore, 3),
@@ -798,7 +998,7 @@ var FootyWireClient = class {
798
998
  const htmlResult = await this.fetchHtml(url);
799
999
  if (!htmlResult.success) return htmlResult;
800
1000
  try {
801
- const $ = cheerio2.load(htmlResult.data);
1001
+ const $ = cheerio3.load(htmlResult.data);
802
1002
  const entries = [];
803
1003
  let currentRound = 0;
804
1004
  let lastHARound = 0;
@@ -924,7 +1124,7 @@ var FootyWireClient = class {
924
1124
  }
925
1125
  };
926
1126
  function parseMatchList(html, year) {
927
- const $ = cheerio2.load(html);
1127
+ const $ = cheerio3.load(html);
928
1128
  const results = [];
929
1129
  let currentRound = 0;
930
1130
  let lastHARound = 0;
@@ -1015,7 +1215,7 @@ function parseMatchList(html, year) {
1015
1215
  return results;
1016
1216
  }
1017
1217
  function parseFixtureList(html, year) {
1018
- const $ = cheerio2.load(html);
1218
+ const $ = cheerio3.load(html);
1019
1219
  const fixtures = [];
1020
1220
  let currentRound = 0;
1021
1221
  let lastHARound = 0;
@@ -1057,18 +1257,45 @@ function parseFixtureList(html, year) {
1057
1257
  season: year,
1058
1258
  roundNumber: currentRound,
1059
1259
  roundType: currentRoundType,
1260
+ roundName: null,
1060
1261
  date,
1061
1262
  venue: normaliseVenueName(venue),
1062
1263
  homeTeam,
1063
1264
  awayTeam,
1265
+ homeGoals: null,
1266
+ homeBehinds: null,
1267
+ homePoints: null,
1268
+ awayGoals: null,
1269
+ awayBehinds: null,
1270
+ awayPoints: null,
1271
+ margin: null,
1272
+ q1Home: null,
1273
+ q2Home: null,
1274
+ q3Home: null,
1275
+ q4Home: null,
1276
+ q1Away: null,
1277
+ q2Away: null,
1278
+ q3Away: null,
1279
+ q4Away: null,
1064
1280
  status: hasScore ? "Complete" : "Upcoming",
1281
+ attendance: null,
1282
+ weatherTempCelsius: null,
1283
+ weatherType: null,
1284
+ roundCode: null,
1285
+ venueState: null,
1286
+ venueTimezone: null,
1287
+ homeRushedBehinds: null,
1288
+ awayRushedBehinds: null,
1289
+ homeMinutesInFront: null,
1290
+ awayMinutesInFront: null,
1291
+ source: "footywire",
1065
1292
  competition: "AFLM"
1066
1293
  });
1067
1294
  });
1068
1295
  return fixtures;
1069
1296
  }
1070
1297
  function parseFootyWireTeamStats(html, year, suffix) {
1071
- const $ = cheerio2.load(html);
1298
+ const $ = cheerio3.load(html);
1072
1299
  const entries = [];
1073
1300
  const tables = $("table");
1074
1301
  const mainTable = tables.length > 10 ? $(tables[10]) : $("table.sortable").first();
@@ -1162,7 +1389,7 @@ function normaliseDob(raw) {
1162
1389
  return raw;
1163
1390
  }
1164
1391
  function parseFootyWirePlayerList(html, teamName) {
1165
- const $ = cheerio2.load(html);
1392
+ const $ = cheerio3.load(html);
1166
1393
  const players = [];
1167
1394
  let dataRows = null;
1168
1395
  $("table").each((_i, table) => {
@@ -1218,9 +1445,9 @@ function parseFootyWirePlayerList(html, teamName) {
1218
1445
  }
1219
1446
 
1220
1447
  // src/transforms/awards.ts
1221
- import * as cheerio3 from "cheerio";
1448
+ import * as cheerio4 from "cheerio";
1222
1449
  function parseBrownlowVotes(html, season) {
1223
- const $ = cheerio3.load(html);
1450
+ const $ = cheerio4.load(html);
1224
1451
  const results = [];
1225
1452
  $("table").each((_i, table) => {
1226
1453
  const rows = $(table).find("tr");
@@ -1259,7 +1486,7 @@ function parseBrownlowVotes(html, season) {
1259
1486
  return results;
1260
1487
  }
1261
1488
  function parseAllAustralian(html, season) {
1262
- const $ = cheerio3.load(html);
1489
+ const $ = cheerio4.load(html);
1263
1490
  const results = [];
1264
1491
  const rows = $("tr");
1265
1492
  rows.each((_i, row) => {
@@ -1289,7 +1516,7 @@ function parseAllAustralian(html, season) {
1289
1516
  return results;
1290
1517
  }
1291
1518
  function parseRisingStarNominations(html, season) {
1292
- const $ = cheerio3.load(html);
1519
+ const $ = cheerio4.load(html);
1293
1520
  const results = [];
1294
1521
  const tables = $("table");
1295
1522
  let targetRows = null;
@@ -1334,255 +1561,175 @@ function parseRisingStarNominations(html, season) {
1334
1561
  return results;
1335
1562
  }
1336
1563
 
1337
- // src/api/awards.ts
1338
- var FOOTYWIRE_BASE2 = "https://www.footywire.com/afl/footy";
1339
- async function fetchAwards(query) {
1340
- const client = new FootyWireClient();
1341
- switch (query.award) {
1342
- case "brownlow": {
1343
- const url = `${FOOTYWIRE_BASE2}/brownlow_medal?year=${query.season}`;
1344
- const htmlResult = await client.fetchPage(url);
1345
- if (!htmlResult.success) return htmlResult;
1346
- const votes = parseBrownlowVotes(htmlResult.data, query.season);
1347
- if (votes.length === 0) {
1348
- return err(
1349
- new ScrapeError(`No Brownlow data found for season ${query.season}`, "footywire")
1350
- );
1351
- }
1352
- return ok(votes);
1353
- }
1354
- case "all-australian": {
1355
- const url = `${FOOTYWIRE_BASE2}/all_australian_selection?year=${query.season}`;
1356
- const htmlResult = await client.fetchPage(url);
1357
- if (!htmlResult.success) return htmlResult;
1358
- const selections = parseAllAustralian(htmlResult.data, query.season);
1359
- if (selections.length === 0) {
1360
- return err(
1361
- new ScrapeError(`No All-Australian data found for season ${query.season}`, "footywire")
1362
- );
1363
- }
1364
- return ok(selections);
1365
- }
1366
- case "rising-star": {
1367
- const url = `${FOOTYWIRE_BASE2}/rising_star_nominations?year=${query.season}`;
1368
- const htmlResult = await client.fetchPage(url);
1369
- if (!htmlResult.success) return htmlResult;
1370
- const nominations = parseRisingStarNominations(htmlResult.data, query.season);
1371
- if (nominations.length === 0) {
1372
- return err(
1373
- new ScrapeError(`No Rising Star data found for season ${query.season}`, "footywire")
1374
- );
1375
- }
1376
- return ok(nominations);
1564
+ // src/lib/concurrency.ts
1565
+ async function batchedMap(items, fn, options) {
1566
+ const batchSize = options?.batchSize ?? 5;
1567
+ const delayMs = options?.delayMs ?? 0;
1568
+ const results = [];
1569
+ for (let i = 0; i < items.length; i += batchSize) {
1570
+ const batch = items.slice(i, i + batchSize);
1571
+ const batchResults = await Promise.all(batch.map(fn));
1572
+ results.push(...batchResults);
1573
+ if (delayMs > 0 && i + batchSize < items.length) {
1574
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1377
1575
  }
1378
- default:
1379
- return err(new ScrapeError(`Unknown award type: ${query.award}`, "footywire"));
1380
1576
  }
1577
+ return results;
1381
1578
  }
1382
1579
 
1383
- // src/sources/afl-coaches.ts
1384
- import * as cheerio4 from "cheerio";
1385
- var AflCoachesClient = class {
1386
- fetchFn;
1387
- constructor(options) {
1388
- this.fetchFn = options?.fetchFn ?? globalThis.fetch.bind(globalThis);
1389
- }
1390
- /**
1391
- * Fetch the HTML content of an AFLCA page.
1392
- */
1393
- async fetchHtml(url) {
1394
- try {
1395
- const response = await this.fetchFn(url, {
1396
- headers: {
1397
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
1398
- }
1399
- });
1400
- if (!response.ok) {
1401
- return err(
1402
- new ScrapeError(`AFL Coaches request failed: ${response.status} (${url})`, "afl-coaches")
1403
- );
1404
- }
1405
- const html = await response.text();
1406
- return ok(html);
1407
- } catch (cause) {
1408
- return err(
1409
- new ScrapeError(
1410
- `AFL Coaches request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1411
- "afl-coaches"
1412
- )
1413
- );
1414
- }
1415
- }
1416
- /**
1417
- * Build the AFLCA leaderboard URL for a given season, round, and competition.
1418
- *
1419
- * Mirrors the R package URL construction from `helper-aflcoaches.R`.
1420
- *
1421
- * @param season - Season year (e.g. 2024).
1422
- * @param roundNumber - Round number.
1423
- * @param competition - "AFLM" or "AFLW".
1424
- * @param isFinals - Whether this is a finals round.
1425
- */
1426
- buildUrl(season, roundNumber, competition, isFinals) {
1427
- const linkBase = competition === "AFLW" ? "https://aflcoaches.com.au/awards/aflw-champion-player-of-the-year-award/leaderboard/" : isFinals ? "https://aflcoaches.com.au/awards/gary-ayres-award-best-finals-player/leaderboard/" : "https://aflcoaches.com.au/awards/the-aflca-champion-player-of-the-year-award/leaderboard/";
1428
- const compSuffix = competition === "AFLW" ? "02" : "01";
1429
- const secondPart = season >= 2023 ? season + 1 : season;
1430
- const roundPad = String(roundNumber).padStart(2, "0");
1431
- return `${linkBase}${season}/${secondPart}${compSuffix}${roundPad}`;
1432
- }
1433
- /**
1434
- * Scrape coaches votes for a single round.
1435
- *
1436
- * @param season - Season year.
1437
- * @param roundNumber - Round number.
1438
- * @param competition - "AFLM" or "AFLW".
1439
- * @param isFinals - Whether this is a finals round.
1440
- * @returns Array of coaches vote records for that round.
1441
- */
1442
- async scrapeRoundVotes(season, roundNumber, competition, isFinals) {
1443
- const url = this.buildUrl(season, roundNumber, competition, isFinals);
1444
- const htmlResult = await this.fetchHtml(url);
1445
- if (!htmlResult.success) {
1446
- return htmlResult;
1447
- }
1448
- try {
1449
- const votes = parseCoachesVotesHtml(htmlResult.data, season, roundNumber);
1450
- return ok(votes);
1451
- } catch (cause) {
1452
- return err(
1453
- new ScrapeError(
1454
- `Failed to parse coaches votes: ${cause instanceof Error ? cause.message : String(cause)}`,
1455
- "afl-coaches"
1456
- )
1457
- );
1458
- }
1459
- }
1460
- /**
1461
- * Fetch coaches votes for an entire season (all rounds).
1462
- *
1463
- * Iterates over rounds 1-30, skipping rounds that return errors (e.g. byes or
1464
- * rounds that haven't been played yet). Finals rounds (>= 24) use the finals URL.
1465
- *
1466
- * @param season - Season year.
1467
- * @param competition - "AFLM" or "AFLW".
1468
- * @returns Combined array of coaches votes for the season.
1469
- */
1470
- async fetchSeasonVotes(season, competition) {
1471
- const allVotes = [];
1472
- const maxRound = 30;
1473
- for (let round = 1; round <= maxRound; round++) {
1474
- const isFinals = round >= 24 && season >= 2018;
1475
- const result = await this.scrapeRoundVotes(season, round, competition, isFinals);
1476
- if (result.success && result.data.length > 0) {
1477
- allVotes.push(...result.data);
1478
- }
1479
- }
1480
- if (allVotes.length === 0) {
1481
- return err(new ScrapeError(`No coaches votes found for season ${season}`, "afl-coaches"));
1482
- }
1483
- return ok(allVotes);
1484
- }
1485
- };
1486
- function parseCoachesVotesHtml(html, season, roundNumber) {
1487
- const $ = cheerio4.load(html);
1488
- const clubLogos = $(".pr-md-3.votes-by-match .club_logo");
1489
- const homeTeams = [];
1490
- const awayTeams = [];
1491
- clubLogos.each((i, el) => {
1492
- const title = $(el).attr("title") ?? "";
1493
- if (i % 2 === 0) {
1494
- homeTeams.push(title);
1495
- } else {
1496
- awayTeams.push(title);
1497
- }
1498
- });
1499
- const rawVotes = [];
1500
- $(".pr-md-3.votes-by-match .col-2").each((_i, el) => {
1501
- const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
1502
- rawVotes.push(text);
1503
- });
1504
- const rawPlayers = [];
1505
- $(".pr-md-3.votes-by-match .col-10").each((_i, el) => {
1506
- const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
1507
- rawPlayers.push(text);
1580
+ // src/transforms/ladder.ts
1581
+ function transformLadderEntries(entries) {
1582
+ return entries.map((entry) => {
1583
+ const record = entry.thisSeasonRecord;
1584
+ const wl = record?.winLossRecord;
1585
+ return {
1586
+ position: entry.position,
1587
+ team: normaliseTeamName(entry.team.name),
1588
+ played: entry.played ?? wl?.played ?? 0,
1589
+ wins: wl?.wins ?? 0,
1590
+ losses: wl?.losses ?? 0,
1591
+ draws: wl?.draws ?? 0,
1592
+ pointsFor: entry.pointsFor ?? 0,
1593
+ pointsAgainst: entry.pointsAgainst ?? 0,
1594
+ percentage: record?.percentage ?? 0,
1595
+ premiershipsPoints: record?.aggregatePoints ?? 0,
1596
+ form: entry.form ?? null
1597
+ };
1508
1598
  });
1509
- const votes = [];
1510
- let matchIndex = 0;
1511
- for (let i = 0; i < rawPlayers.length; i++) {
1512
- const playerName = rawPlayers[i] ?? "";
1513
- const voteText = rawVotes[i] ?? "";
1514
- if (playerName === "Player (Club)" && voteText === "Votes") {
1515
- matchIndex++;
1516
- continue;
1517
- }
1518
- const homeTeam = homeTeams[matchIndex - 1];
1519
- const awayTeam = awayTeams[matchIndex - 1];
1520
- if (homeTeam == null || awayTeam == null) {
1521
- continue;
1522
- }
1523
- const voteCount = Number(voteText);
1524
- if (Number.isNaN(voteCount)) {
1525
- continue;
1526
- }
1527
- votes.push({
1528
- season,
1529
- round: roundNumber,
1530
- homeTeam,
1531
- awayTeam,
1532
- playerName,
1533
- votes: voteCount
1534
- });
1535
- }
1536
- return votes;
1537
1599
  }
1538
1600
 
1539
- // src/api/coaches-votes.ts
1540
- async function fetchCoachesVotes(query) {
1541
- const competition = query.competition ?? "AFLM";
1542
- if (query.season < 2006) {
1543
- return err(new ScrapeError("No coaches votes data available before 2006", "afl-coaches"));
1544
- }
1545
- if (competition === "AFLW" && query.season < 2018) {
1546
- return err(new ScrapeError("No AFLW coaches votes data available before 2018", "afl-coaches"));
1547
- }
1548
- const client = new AflCoachesClient();
1549
- let result;
1550
- if (query.round != null) {
1551
- const isFinals = query.round >= 24 && query.season >= 2018;
1552
- result = await client.scrapeRoundVotes(query.season, query.round, competition, isFinals);
1553
- } else {
1554
- result = await client.fetchSeasonVotes(query.season, competition);
1555
- }
1556
- if (!result.success) {
1557
- return result;
1558
- }
1559
- let votes = result.data;
1560
- if (query.team != null) {
1561
- const normalisedTeam = normaliseTeamName(query.team);
1562
- votes = votes.filter(
1563
- (v) => normaliseTeamName(v.homeTeam) === normalisedTeam || normaliseTeamName(v.awayTeam) === normalisedTeam
1564
- );
1565
- if (votes.length === 0) {
1566
- return ok([]);
1567
- }
1568
- }
1569
- return ok(votes);
1601
+ // src/transforms/lineup.ts
1602
+ var EMERGENCY_POSITIONS = /* @__PURE__ */ new Set(["EMG", "EMERG"]);
1603
+ var SUBSTITUTE_POSITIONS = /* @__PURE__ */ new Set(["SUB", "INT"]);
1604
+ function transformMatchRoster(roster, season, roundNumber, competition) {
1605
+ const homeTeamId = roster.match.homeTeamId;
1606
+ const awayTeamId = roster.match.awayTeamId;
1607
+ const homeTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === homeTeamId);
1608
+ const awayTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === awayTeamId);
1609
+ const mapPlayers = (players) => players.map((p) => {
1610
+ const inner = p.player.player;
1611
+ const position = p.player.position ?? null;
1612
+ return {
1613
+ playerId: inner.playerId,
1614
+ givenName: inner.playerName.givenName,
1615
+ surname: inner.playerName.surname,
1616
+ displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
1617
+ jumperNumber: p.jumperNumber ?? null,
1618
+ position,
1619
+ isEmergency: position !== null && EMERGENCY_POSITIONS.has(position),
1620
+ isSubstitute: position !== null && SUBSTITUTE_POSITIONS.has(position)
1621
+ };
1622
+ });
1623
+ return {
1624
+ matchId: roster.match.matchId,
1625
+ season,
1626
+ roundNumber,
1627
+ homeTeam: normaliseTeamName(roster.match.homeTeam.name),
1628
+ awayTeam: normaliseTeamName(roster.match.awayTeam.name),
1629
+ homePlayers: homeTeamPlayers ? mapPlayers(homeTeamPlayers.players) : [],
1630
+ awayPlayers: awayTeamPlayers ? mapPlayers(awayTeamPlayers.players) : [],
1631
+ competition
1632
+ };
1570
1633
  }
1571
1634
 
1572
- // src/lib/concurrency.ts
1573
- async function batchedMap(items, fn, options) {
1574
- const batchSize = options?.batchSize ?? 5;
1575
- const delayMs = options?.delayMs ?? 0;
1576
- const results = [];
1577
- for (let i = 0; i < items.length; i += batchSize) {
1578
- const batch = items.slice(i, i + batchSize);
1579
- const batchResults = await Promise.all(batch.map(fn));
1580
- results.push(...batchResults);
1581
- if (delayMs > 0 && i + batchSize < items.length) {
1582
- await new Promise((resolve) => setTimeout(resolve, delayMs));
1583
- }
1584
- }
1585
- return results;
1635
+ // src/transforms/player-stats.ts
1636
+ function toNullable(value) {
1637
+ return value ?? null;
1638
+ }
1639
+ function transformOne(item, ctx) {
1640
+ const inner = item.player.player.player;
1641
+ const stats = item.playerStats?.stats;
1642
+ const clearances = stats?.clearances;
1643
+ return {
1644
+ matchId: ctx.matchId,
1645
+ season: ctx.season,
1646
+ roundNumber: ctx.roundNumber,
1647
+ team: normaliseTeamName(
1648
+ ctx.teamIdMap?.get(item.teamId) ?? AFL_API_TEAM_IDS.get(item.teamId) ?? item.teamId
1649
+ ),
1650
+ competition: ctx.competition,
1651
+ date: ctx.date ?? null,
1652
+ homeTeam: ctx.homeTeam ?? null,
1653
+ awayTeam: ctx.awayTeam ?? null,
1654
+ playerId: inner.playerId,
1655
+ givenName: inner.playerName.givenName,
1656
+ surname: inner.playerName.surname,
1657
+ displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
1658
+ jumperNumber: item.player.jumperNumber ?? null,
1659
+ kicks: toNullable(stats?.kicks),
1660
+ handballs: toNullable(stats?.handballs),
1661
+ disposals: toNullable(stats?.disposals),
1662
+ marks: toNullable(stats?.marks),
1663
+ goals: toNullable(stats?.goals),
1664
+ behinds: toNullable(stats?.behinds),
1665
+ tackles: toNullable(stats?.tackles),
1666
+ hitouts: toNullable(stats?.hitouts),
1667
+ freesFor: toNullable(stats?.freesFor),
1668
+ freesAgainst: toNullable(stats?.freesAgainst),
1669
+ contestedPossessions: toNullable(stats?.contestedPossessions),
1670
+ uncontestedPossessions: toNullable(stats?.uncontestedPossessions),
1671
+ contestedMarks: toNullable(stats?.contestedMarks),
1672
+ intercepts: toNullable(stats?.intercepts),
1673
+ centreClearances: toNullable(clearances?.centreClearances),
1674
+ stoppageClearances: toNullable(clearances?.stoppageClearances),
1675
+ totalClearances: toNullable(clearances?.totalClearances),
1676
+ inside50s: toNullable(stats?.inside50s),
1677
+ rebound50s: toNullable(stats?.rebound50s),
1678
+ clangers: toNullable(stats?.clangers),
1679
+ turnovers: toNullable(stats?.turnovers),
1680
+ onePercenters: toNullable(stats?.onePercenters),
1681
+ bounces: toNullable(stats?.bounces),
1682
+ goalAssists: toNullable(stats?.goalAssists),
1683
+ disposalEfficiency: toNullable(stats?.disposalEfficiency),
1684
+ metresGained: toNullable(stats?.metresGained),
1685
+ goalAccuracy: toNullable(stats?.goalAccuracy),
1686
+ marksInside50: toNullable(stats?.marksInside50),
1687
+ tacklesInside50: toNullable(stats?.tacklesInside50),
1688
+ shotsAtGoal: toNullable(stats?.shotsAtGoal),
1689
+ scoreInvolvements: toNullable(stats?.scoreInvolvements),
1690
+ totalPossessions: toNullable(stats?.totalPossessions),
1691
+ timeOnGroundPercentage: toNullable(item.playerStats?.timeOnGroundPercentage),
1692
+ ratingPoints: toNullable(stats?.ratingPoints),
1693
+ position: item.player.player.position ?? null,
1694
+ goalEfficiency: toNullable(stats?.goalEfficiency),
1695
+ shotEfficiency: toNullable(stats?.shotEfficiency),
1696
+ interchangeCounts: toNullable(stats?.interchangeCounts),
1697
+ brownlowVotes: toNullable(stats?.brownlowVotes),
1698
+ supercoachScore: null,
1699
+ dreamTeamPoints: toNullable(stats?.dreamTeamPoints),
1700
+ effectiveDisposals: toNullable(stats?.extendedStats?.effectiveDisposals),
1701
+ effectiveKicks: toNullable(stats?.extendedStats?.effectiveKicks),
1702
+ kickEfficiency: toNullable(stats?.extendedStats?.kickEfficiency),
1703
+ kickToHandballRatio: toNullable(stats?.extendedStats?.kickToHandballRatio),
1704
+ pressureActs: toNullable(stats?.extendedStats?.pressureActs),
1705
+ defHalfPressureActs: toNullable(stats?.extendedStats?.defHalfPressureActs),
1706
+ spoils: toNullable(stats?.extendedStats?.spoils),
1707
+ hitoutsToAdvantage: toNullable(stats?.extendedStats?.hitoutsToAdvantage),
1708
+ hitoutWinPercentage: toNullable(stats?.extendedStats?.hitoutWinPercentage),
1709
+ hitoutToAdvantageRate: toNullable(stats?.extendedStats?.hitoutToAdvantageRate),
1710
+ groundBallGets: toNullable(stats?.extendedStats?.groundBallGets),
1711
+ f50GroundBallGets: toNullable(stats?.extendedStats?.f50GroundBallGets),
1712
+ interceptMarks: toNullable(stats?.extendedStats?.interceptMarks),
1713
+ marksOnLead: toNullable(stats?.extendedStats?.marksOnLead),
1714
+ contestedPossessionRate: toNullable(stats?.extendedStats?.contestedPossessionRate),
1715
+ contestOffOneOnOnes: toNullable(stats?.extendedStats?.contestOffOneOnOnes),
1716
+ contestOffWins: toNullable(stats?.extendedStats?.contestOffWins),
1717
+ contestOffWinsPercentage: toNullable(stats?.extendedStats?.contestOffWinsPercentage),
1718
+ contestDefOneOnOnes: toNullable(stats?.extendedStats?.contestDefOneOnOnes),
1719
+ contestDefLosses: toNullable(stats?.extendedStats?.contestDefLosses),
1720
+ contestDefLossPercentage: toNullable(stats?.extendedStats?.contestDefLossPercentage),
1721
+ centreBounceAttendances: toNullable(stats?.extendedStats?.centreBounceAttendances),
1722
+ kickins: toNullable(stats?.extendedStats?.kickins),
1723
+ kickinsPlayon: toNullable(stats?.extendedStats?.kickinsPlayon),
1724
+ ruckContests: toNullable(stats?.extendedStats?.ruckContests),
1725
+ scoreLaunches: toNullable(stats?.extendedStats?.scoreLaunches),
1726
+ source: ctx.source
1727
+ };
1728
+ }
1729
+ function transformPlayerStats(data, ctx) {
1730
+ const home = (data.homeTeamPlayerStats ?? []).map((item) => transformOne(item, ctx));
1731
+ const away = (data.awayTeamPlayerStats ?? []).map((item) => transformOne(item, ctx));
1732
+ return [...home, ...away];
1586
1733
  }
1587
1734
 
1588
1735
  // src/lib/validation.ts
@@ -1885,6 +2032,18 @@ var USER_AGENT = "fitzroy/2 (https://github.com/jackemcpherson/fitzRoy-ts)";
1885
2032
  var TOKEN_URL = "https://api.afl.com.au/cfs/afl/WMCTok";
1886
2033
  var API_BASE = "https://aflapi.afl.com.au/afl/v2";
1887
2034
  var CFS_BASE = "https://api.afl.com.au/cfs/afl";
2035
+ var AFL_API_COMP_IDS = {
2036
+ AFLM: 1,
2037
+ AFLW: 3,
2038
+ VFL: 7,
2039
+ VFLW: 11
2040
+ };
2041
+ var AFL_API_TEAM_TYPES = {
2042
+ AFLM: "MEN",
2043
+ AFLW: "WOMEN",
2044
+ VFL: "VFL_MEN",
2045
+ VFLW: "VFL_WOMEN"
2046
+ };
1888
2047
  var AflApiClient = class {
1889
2048
  fetchFn;
1890
2049
  tokenUrl;
@@ -2059,23 +2218,16 @@ var AflApiClient = class {
2059
2218
  /**
2060
2219
  * Resolve a competition code (e.g. "AFLM") to its API competition ID.
2061
2220
  *
2221
+ * Returns the hardcoded mapping from {@link AFL_API_COMP_IDS}. The previous
2222
+ * implementation looked up by `code` field on `/competitions`, but four
2223
+ * competitions share `code="AFL"` (Premiership, Preseason, Origin,
2224
+ * Indigenous All Stars), so the lookup was load-bearing on response order.
2225
+ *
2062
2226
  * @param code - The competition code to resolve.
2063
- * @returns The competition ID string on success.
2227
+ * @returns The competition ID on success.
2064
2228
  */
2065
2229
  async resolveCompetitionId(code) {
2066
- const result = await this.fetchJson(
2067
- `${API_BASE}/competitions?pageSize=50`,
2068
- CompetitionListSchema
2069
- );
2070
- if (!result.success) {
2071
- return result;
2072
- }
2073
- const apiCode = code === "AFLM" ? "AFL" : code;
2074
- const competition = result.data.competitions.find((c) => c.code === apiCode);
2075
- if (!competition) {
2076
- return err(new AflApiError(`Competition not found for code: ${code}`));
2077
- }
2078
- return ok(competition.id);
2230
+ return ok(AFL_API_COMP_IDS[code]);
2079
2231
  }
2080
2232
  /**
2081
2233
  * Resolve a season (compseason) ID from a competition ID and year.
@@ -2167,22 +2319,23 @@ var AflApiClient = class {
2167
2319
  * @param seasonId - The compseason ID.
2168
2320
  * @returns Aggregated array of match items from all completed rounds.
2169
2321
  */
2170
- async fetchSeasonMatchItems(seasonId) {
2322
+ async fetchSeasonMatchItems(seasonId, options) {
2171
2323
  const roundsResult = await this.resolveRounds(seasonId);
2172
2324
  if (!roundsResult.success) {
2173
2325
  return roundsResult;
2174
2326
  }
2175
2327
  const providerIds = roundsResult.data.flatMap((r) => r.providerId ? [r.providerId] : []);
2176
2328
  const results = await batchedMap(providerIds, (id) => this.fetchRoundMatchItems(id));
2329
+ const includeUpcoming = options?.includeUpcoming ?? false;
2177
2330
  const allItems = [];
2178
2331
  for (const result of results) {
2179
2332
  if (!result.success) {
2180
2333
  return result;
2181
2334
  }
2182
- const concluded = result.data.filter(
2335
+ const items = includeUpcoming ? result.data : result.data.filter(
2183
2336
  (item) => item.match.status === "CONCLUDED" || item.match.status === "COMPLETE"
2184
2337
  );
2185
- allItems.push(...concluded);
2338
+ allItems.push(...items);
2186
2339
  }
2187
2340
  return ok(allItems);
2188
2341
  }
@@ -2208,17 +2361,21 @@ var AflApiClient = class {
2208
2361
  return this.fetchJson(`${CFS_BASE}/matchRoster/full/${matchProviderId}`, MatchRosterSchema);
2209
2362
  }
2210
2363
  /**
2211
- * Fetch team list, optionally filtered by team type.
2364
+ * Fetch team list, optionally filtered by competition.
2365
+ *
2366
+ * Pass a `CompetitionCode` to scope the result to that competition's teams
2367
+ * (uses {@link AFL_API_TEAM_TYPES} internally).
2212
2368
  *
2213
- * @param teamType - Optional filter (e.g. "MEN", "WOMEN").
2369
+ * @param competition - Optional CompetitionCode filter (e.g. "AFLM", "VFL").
2214
2370
  * @returns Array of team items.
2215
2371
  */
2216
- async fetchTeams(teamType) {
2217
- const result = await this.fetchJson(`${API_BASE}/teams?pageSize=100`, TeamListSchema);
2372
+ async fetchTeams(competition) {
2373
+ const result = await this.fetchJson(`${API_BASE}/teams?pageSize=500`, TeamListSchema);
2218
2374
  if (!result.success) {
2219
2375
  return result;
2220
2376
  }
2221
- if (teamType) {
2377
+ if (competition) {
2378
+ const teamType = AFL_API_TEAM_TYPES[competition];
2222
2379
  return ok(result.data.teams.filter((t) => t.teamType === teamType));
2223
2380
  }
2224
2381
  return ok(result.data.teams);
@@ -2252,281 +2409,282 @@ var AflApiClient = class {
2252
2409
  }
2253
2410
  };
2254
2411
 
2255
- // src/lib/squiggle-validation.ts
2256
- import { z as z2 } from "zod";
2257
- var SquiggleGameSchema = z2.object({
2258
- id: z2.number(),
2259
- year: z2.number(),
2260
- round: z2.number(),
2261
- roundname: z2.string(),
2262
- hteam: z2.string(),
2263
- ateam: z2.string(),
2264
- hteamid: z2.number(),
2265
- ateamid: z2.number(),
2266
- hscore: z2.number().nullable(),
2267
- ascore: z2.number().nullable(),
2268
- hgoals: z2.number().nullable(),
2269
- agoals: z2.number().nullable(),
2270
- hbehinds: z2.number().nullable(),
2271
- abehinds: z2.number().nullable(),
2272
- winner: z2.string().nullable(),
2273
- winnerteamid: z2.number().nullable(),
2274
- venue: z2.string(),
2275
- date: z2.string(),
2276
- localtime: z2.string(),
2277
- tz: z2.string(),
2278
- unixtime: z2.number(),
2279
- timestr: z2.string().nullable(),
2280
- complete: z2.number(),
2281
- is_final: z2.number(),
2282
- is_grand_final: z2.number(),
2283
- updated: z2.string()
2284
- });
2285
- var SquiggleGamesResponseSchema = z2.object({
2286
- games: z2.array(SquiggleGameSchema)
2287
- });
2288
- var SquiggleStandingSchema = z2.object({
2289
- id: z2.number(),
2290
- name: z2.string(),
2291
- rank: z2.number(),
2292
- played: z2.number(),
2293
- wins: z2.number(),
2294
- losses: z2.number(),
2295
- draws: z2.number(),
2296
- pts: z2.number(),
2297
- for: z2.number(),
2298
- against: z2.number(),
2299
- percentage: z2.number(),
2300
- goals_for: z2.number(),
2301
- goals_against: z2.number(),
2302
- behinds_for: z2.number(),
2303
- behinds_against: z2.number()
2304
- });
2305
- var SquiggleStandingsResponseSchema = z2.object({
2306
- standings: z2.array(SquiggleStandingSchema)
2307
- });
2308
-
2309
- // src/sources/squiggle.ts
2310
- var SQUIGGLE_BASE = "https://api.squiggle.com.au/";
2311
- var USER_AGENT2 = "fitzRoy-ts/1.0 (https://github.com/jackemcpherson/fitzRoy-ts)";
2312
- var SquiggleClient = class {
2313
- fetchFn;
2314
- constructor(options) {
2315
- this.fetchFn = options?.fetchFn ?? globalThis.fetch.bind(globalThis);
2412
+ // src/sources/adapters/afl-api.ts
2413
+ var AFL_API_COVERAGE = /* @__PURE__ */ new Map([
2414
+ ["AFLM", { minSeason: 2012 }],
2415
+ ["AFLW", { minSeason: 2017 }],
2416
+ ["VFL", { minSeason: 2021 }],
2417
+ ["VFLW", { minSeason: 2021 }]
2418
+ ]);
2419
+ var AflApiMatchSource = class {
2420
+ constructor(client = new AflApiClient()) {
2421
+ this.client = client;
2316
2422
  }
2317
- /**
2318
- * Fetch JSON from the Squiggle API.
2319
- */
2320
- async fetchJson(params) {
2321
- const url = `${SQUIGGLE_BASE}?${params.toString()}`;
2322
- try {
2323
- const response = await this.fetchFn(url, {
2324
- headers: { "User-Agent": USER_AGENT2 }
2325
- });
2326
- if (!response.ok) {
2327
- return err(
2328
- new ScrapeError(`Squiggle request failed: ${response.status} (${url})`, "squiggle")
2329
- );
2423
+ id = "afl-api";
2424
+ coverage = AFL_API_COVERAGE;
2425
+ async fetchMatches(query) {
2426
+ const competition = query.competition ?? "AFLM";
2427
+ const seasonResult = await this.client.resolveCompSeason(competition, query.season);
2428
+ if (!seasonResult.success) return seasonResult;
2429
+ const includeUpcoming = query.status !== "Complete";
2430
+ const itemsResult = query.round != null ? await this.client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round) : await this.client.fetchSeasonMatchItems(seasonResult.data, { includeUpcoming });
2431
+ if (!itemsResult.success) return itemsResult;
2432
+ return ok(transformMatchItems(itemsResult.data, query.season, competition));
2433
+ }
2434
+ };
2435
+ var AflApiPlayerStatsSource = class {
2436
+ constructor(client = new AflApiClient()) {
2437
+ this.client = client;
2438
+ }
2439
+ id = "afl-api";
2440
+ coverage = AFL_API_COVERAGE;
2441
+ async fetchPlayerStats(query) {
2442
+ const competition = query.competition ?? "AFLM";
2443
+ if (query.matchId) {
2444
+ const [rosterResult, statsResult] = await Promise.all([
2445
+ this.client.fetchMatchRoster(query.matchId),
2446
+ this.client.fetchPlayerStats(query.matchId)
2447
+ ]);
2448
+ if (!statsResult.success) return statsResult;
2449
+ const teamIdMap2 = new Map(AFL_API_TEAM_IDS);
2450
+ if (rosterResult.success) {
2451
+ const match = rosterResult.data.match;
2452
+ teamIdMap2.set(match.homeTeamId, normaliseTeamName(match.homeTeam.name));
2453
+ teamIdMap2.set(match.awayTeamId, normaliseTeamName(match.awayTeam.name));
2330
2454
  }
2331
- const json = await response.json();
2332
- return ok(json);
2333
- } catch (cause) {
2334
- return err(
2335
- new ScrapeError(
2336
- `Squiggle request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2337
- "squiggle"
2338
- )
2455
+ return ok(
2456
+ transformPlayerStats(statsResult.data, {
2457
+ matchId: query.matchId,
2458
+ season: query.season,
2459
+ roundNumber: query.round ?? 0,
2460
+ competition,
2461
+ source: "afl-api",
2462
+ teamIdMap: teamIdMap2
2463
+ })
2339
2464
  );
2340
2465
  }
2341
- }
2342
- /**
2343
- * Fetch games (match results or fixture) from the Squiggle API.
2344
- *
2345
- * @param year - Season year.
2346
- * @param round - Optional round number.
2347
- * @param complete - Optional completion filter (100 = complete, omit for all).
2348
- */
2349
- async fetchGames(year, round, complete) {
2350
- const params = new URLSearchParams({ q: "games", year: String(year) });
2351
- if (round != null) params.set("round", String(round));
2352
- if (complete != null) params.set("complete", String(complete));
2353
- const result = await this.fetchJson(params);
2354
- if (!result.success) return result;
2355
- const parsed = SquiggleGamesResponseSchema.safeParse(result.data);
2356
- if (!parsed.success) {
2357
- return err(
2358
- new ScrapeError(`Invalid Squiggle games response: ${parsed.error.message}`, "squiggle")
2466
+ const seasonResult = await this.client.resolveCompSeason(competition, query.season);
2467
+ if (!seasonResult.success) return seasonResult;
2468
+ const matchItemsResult = query.round != null ? await this.client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round) : await this.client.fetchSeasonMatchItems(seasonResult.data);
2469
+ if (!matchItemsResult.success) return matchItemsResult;
2470
+ const teamIdMap = /* @__PURE__ */ new Map();
2471
+ for (const item of matchItemsResult.data) {
2472
+ teamIdMap.set(item.match.homeTeamId, item.match.homeTeam.name);
2473
+ teamIdMap.set(item.match.awayTeamId, item.match.awayTeam.name);
2474
+ }
2475
+ const statsResults = await batchedMap(
2476
+ matchItemsResult.data,
2477
+ (item) => this.client.fetchPlayerStats(item.match.matchId)
2478
+ );
2479
+ const allStats = [];
2480
+ for (let i = 0; i < statsResults.length; i++) {
2481
+ const statsResult = statsResults[i];
2482
+ if (!statsResult?.success) {
2483
+ return statsResult ?? err(new AflApiError("Missing stats result"));
2484
+ }
2485
+ const item = matchItemsResult.data[i];
2486
+ if (!item) continue;
2487
+ allStats.push(
2488
+ ...transformPlayerStats(statsResult.data, {
2489
+ matchId: item.match.matchId,
2490
+ season: query.season,
2491
+ roundNumber: item.round?.roundNumber ?? query.round ?? 0,
2492
+ competition,
2493
+ source: "afl-api",
2494
+ teamIdMap,
2495
+ date: parseDate(item.match.utcStartTime) ?? new Date(item.match.utcStartTime),
2496
+ homeTeam: normaliseTeamName(item.match.homeTeam.name),
2497
+ awayTeam: normaliseTeamName(item.match.awayTeam.name)
2498
+ })
2359
2499
  );
2360
2500
  }
2361
- return ok(parsed.data);
2501
+ return ok(allStats);
2362
2502
  }
2363
- /**
2364
- * Fetch standings (ladder) from the Squiggle API.
2365
- *
2366
- * @param year - Season year.
2367
- * @param round - Optional round number.
2368
- */
2369
- async fetchStandings(year, round) {
2370
- const params = new URLSearchParams({ q: "standings", year: String(year) });
2371
- if (round != null) params.set("round", String(round));
2372
- const result = await this.fetchJson(params);
2373
- if (!result.success) return result;
2374
- const parsed = SquiggleStandingsResponseSchema.safeParse(result.data);
2375
- if (!parsed.success) {
2376
- return err(
2377
- new ScrapeError(`Invalid Squiggle standings response: ${parsed.error.message}`, "squiggle")
2378
- );
2503
+ };
2504
+ var AflApiSquadSource = class {
2505
+ constructor(client = new AflApiClient()) {
2506
+ this.client = client;
2507
+ }
2508
+ id = "afl-api";
2509
+ coverage = AFL_API_COVERAGE;
2510
+ async fetchSquad(query) {
2511
+ const competition = query.competition ?? "AFLM";
2512
+ const seasonResult = await this.client.resolveCompSeason(competition, query.season);
2513
+ if (!seasonResult.success) return seasonResult;
2514
+ const teamIdResult = await this.resolveTeamId(query.team, competition);
2515
+ if (!teamIdResult.success) return teamIdResult;
2516
+ const squadResult = await this.client.fetchSquad(teamIdResult.data, seasonResult.data);
2517
+ if (!squadResult.success) return squadResult;
2518
+ const players = squadResult.data.squad.players.map((p) => ({
2519
+ playerId: p.player.providerId ?? String(p.player.id),
2520
+ givenName: p.player.firstName,
2521
+ surname: p.player.surname,
2522
+ displayName: `${p.player.firstName} ${p.player.surname}`,
2523
+ jumperNumber: p.jumperNumber ?? null,
2524
+ position: p.position ?? null,
2525
+ dateOfBirth: p.player.dateOfBirth ? parseDate(p.player.dateOfBirth) : null,
2526
+ heightCm: p.player.heightInCm || null,
2527
+ weightKg: p.player.weightInKg || null,
2528
+ draftYear: p.player.draftYear ? Number.parseInt(p.player.draftYear, 10) || null : null,
2529
+ draftPosition: p.player.draftPosition ? Number.parseInt(p.player.draftPosition, 10) || null : null,
2530
+ draftType: p.player.draftType ?? null,
2531
+ debutYear: p.player.debutYear ? Number.parseInt(p.player.debutYear, 10) || null : null,
2532
+ recruitedFrom: p.player.recruitedFrom ?? null
2533
+ }));
2534
+ return ok({
2535
+ teamId: String(teamIdResult.data),
2536
+ teamName: normaliseTeamName(squadResult.data.squad.team?.name ?? query.team),
2537
+ season: query.season,
2538
+ players,
2539
+ competition
2540
+ });
2541
+ }
2542
+ /** Resolve a canonical team name to the AFL API's numeric team ID. */
2543
+ async resolveTeamId(teamName, competition) {
2544
+ const teamsResult = await this.client.fetchTeams(competition);
2545
+ if (!teamsResult.success) return teamsResult;
2546
+ const normalised = normaliseTeamName(teamName);
2547
+ const match = teamsResult.data.find((t) => normaliseTeamName(t.name) === normalised);
2548
+ if (!match) {
2549
+ return err(new ValidationError(`Team not found in ${competition}: ${teamName}`));
2379
2550
  }
2380
- return ok(parsed.data);
2551
+ return ok(match.id);
2381
2552
  }
2382
2553
  };
2383
-
2384
- // src/transforms/squiggle.ts
2385
- function toMatchStatus2(complete) {
2386
- if (complete === 100) return "Complete";
2387
- if (complete > 0) return "Live";
2388
- return "Upcoming";
2389
- }
2390
- function transformSquiggleGamesToResults(games, season) {
2391
- return games.filter((g) => g.complete === 100).map((g) => ({
2392
- matchId: `SQ_${g.id}`,
2393
- season,
2394
- roundNumber: g.round,
2395
- roundType: inferRoundType(g.roundname),
2396
- roundName: g.roundname || null,
2397
- date: parseDate(g.unixtime) ?? new Date(g.unixtime * 1e3),
2398
- venue: normaliseVenueName(g.venue),
2399
- homeTeam: normaliseTeamName(g.hteam),
2400
- awayTeam: normaliseTeamName(g.ateam),
2401
- homeGoals: g.hgoals ?? 0,
2402
- homeBehinds: g.hbehinds ?? 0,
2403
- homePoints: g.hscore ?? 0,
2404
- awayGoals: g.agoals ?? 0,
2405
- awayBehinds: g.abehinds ?? 0,
2406
- awayPoints: g.ascore ?? 0,
2407
- margin: (g.hscore ?? 0) - (g.ascore ?? 0),
2408
- q1Home: null,
2409
- q2Home: null,
2410
- q3Home: null,
2411
- q4Home: null,
2412
- q1Away: null,
2413
- q2Away: null,
2414
- q3Away: null,
2415
- q4Away: null,
2416
- status: "Complete",
2417
- attendance: null,
2418
- weatherTempCelsius: null,
2419
- weatherType: null,
2420
- roundCode: toRoundCode(g.roundname),
2421
- venueState: null,
2422
- venueTimezone: g.tz || null,
2423
- homeRushedBehinds: null,
2424
- awayRushedBehinds: null,
2425
- homeMinutesInFront: null,
2426
- awayMinutesInFront: null,
2427
- source: "squiggle",
2428
- competition: "AFLM"
2429
- }));
2430
- }
2431
- function transformSquiggleGamesToFixture(games, season) {
2432
- return games.map((g) => ({
2433
- matchId: `SQ_${g.id}`,
2434
- season,
2435
- roundNumber: g.round,
2436
- roundType: inferRoundType(g.roundname),
2437
- date: parseDate(g.unixtime) ?? new Date(g.unixtime * 1e3),
2438
- venue: normaliseVenueName(g.venue),
2439
- homeTeam: normaliseTeamName(g.hteam),
2440
- awayTeam: normaliseTeamName(g.ateam),
2441
- status: toMatchStatus2(g.complete),
2442
- competition: "AFLM"
2443
- }));
2444
- }
2445
- function transformSquiggleStandings(standings) {
2446
- return standings.map((s) => ({
2447
- position: s.rank,
2448
- team: normaliseTeamName(s.name),
2449
- played: s.played,
2450
- wins: s.wins,
2451
- losses: s.losses,
2452
- draws: s.draws,
2453
- pointsFor: s.for,
2454
- pointsAgainst: s.against,
2455
- percentage: s.percentage,
2456
- premiershipsPoints: s.pts,
2457
- form: null
2458
- }));
2459
- }
2460
-
2461
- // src/api/fixture.ts
2462
- function toFixture(item, season, fallbackRoundNumber, competition) {
2463
- return {
2464
- matchId: item.match.matchId,
2465
- season,
2466
- roundNumber: item.round?.roundNumber ?? fallbackRoundNumber,
2467
- roundType: inferRoundType(item.round?.name ?? ""),
2468
- date: parseDate(item.match.utcStartTime) ?? new Date(item.match.utcStartTime),
2469
- venue: normaliseVenueName(item.venue?.name ?? ""),
2470
- homeTeam: normaliseTeamName(item.match.homeTeam.name),
2471
- awayTeam: normaliseTeamName(item.match.awayTeam.name),
2472
- status: toMatchStatus(item.match.status),
2473
- competition
2474
- };
2475
- }
2476
- async function fetchFixture(query) {
2477
- const competition = query.competition ?? "AFLM";
2478
- if (query.source === "squiggle") {
2479
- if (competition === "AFLW") return err(aflwUnsupportedError("squiggle"));
2480
- const client2 = new SquiggleClient();
2481
- const result = await client2.fetchGames(query.season, query.round ?? void 0);
2482
- if (!result.success) return result;
2483
- return ok(transformSquiggleGamesToFixture(result.data.games, query.season));
2554
+ var AflApiLineupSource = class {
2555
+ constructor(client = new AflApiClient()) {
2556
+ this.client = client;
2484
2557
  }
2485
- if (query.source === "footywire") {
2486
- if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
2487
- const fwClient = new FootyWireClient();
2488
- const result = await fwClient.fetchSeasonFixture(query.season);
2489
- if (!result.success) return result;
2490
- if (query.round != null) {
2491
- return ok(result.data.filter((f) => f.roundNumber === query.round));
2558
+ id = "afl-api";
2559
+ coverage = AFL_API_COVERAGE;
2560
+ async fetchLineup(query) {
2561
+ const competition = query.competition ?? "AFLM";
2562
+ if (query.matchId) {
2563
+ const rosterResult = await this.client.fetchMatchRoster(query.matchId);
2564
+ if (!rosterResult.success) return rosterResult;
2565
+ return ok([transformMatchRoster(rosterResult.data, query.season, query.round, competition)]);
2492
2566
  }
2493
- return result;
2494
- }
2495
- if (query.source !== "afl-api") {
2496
- return err(
2497
- new UnsupportedSourceError(
2498
- "Fixture data is only available from the AFL API, FootyWire, or Squiggle sources.",
2499
- query.source
2500
- )
2567
+ const seasonResult = await this.client.resolveCompSeason(competition, query.season);
2568
+ if (!seasonResult.success) return seasonResult;
2569
+ const matchItems = await this.client.fetchRoundMatchItemsByNumber(
2570
+ seasonResult.data,
2571
+ query.round
2572
+ );
2573
+ if (!matchItems.success) return matchItems;
2574
+ if (matchItems.data.length === 0) {
2575
+ return err(new AflApiError(`No matches found for round ${query.round}`));
2576
+ }
2577
+ const rosterResults = await batchedMap(
2578
+ matchItems.data,
2579
+ (item) => this.client.fetchMatchRoster(item.match.matchId)
2501
2580
  );
2581
+ const lineups = [];
2582
+ for (const rosterResult of rosterResults) {
2583
+ if (!rosterResult.success) return rosterResult;
2584
+ lineups.push(transformMatchRoster(rosterResult.data, query.season, query.round, competition));
2585
+ }
2586
+ return ok(lineups);
2502
2587
  }
2503
- const client = new AflApiClient();
2504
- const seasonResult = await client.resolveCompSeason(competition, query.season);
2505
- if (!seasonResult.success) return seasonResult;
2506
- if (query.round != null) {
2507
- const itemsResult = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
2508
- if (!itemsResult.success) return itemsResult;
2509
- return ok(itemsResult.data.map((item) => toFixture(item, query.season, 0, competition)));
2588
+ };
2589
+ var AflApiLadderSource = class {
2590
+ constructor(client = new AflApiClient()) {
2591
+ this.client = client;
2510
2592
  }
2511
- const roundsResult = await client.resolveRounds(seasonResult.data);
2512
- if (!roundsResult.success) return roundsResult;
2513
- const roundProviderIds = roundsResult.data.flatMap(
2514
- (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
2515
- );
2516
- const roundResults = await batchedMap(
2517
- roundProviderIds,
2518
- (r) => client.fetchRoundMatchItems(r.providerId)
2519
- );
2520
- const fixtures = [];
2521
- for (let i = 0; i < roundResults.length; i++) {
2522
- const result = roundResults[i];
2523
- if (!result?.success) continue;
2524
- const roundNumber = roundProviderIds[i]?.roundNumber ?? 0;
2525
- for (const item of result.data) {
2526
- fixtures.push(toFixture(item, query.season, roundNumber, competition));
2593
+ id = "afl-api";
2594
+ coverage = AFL_API_COVERAGE;
2595
+ async fetchLadder(query) {
2596
+ const competition = query.competition ?? "AFLM";
2597
+ const seasonResult = await this.client.resolveCompSeason(competition, query.season);
2598
+ if (!seasonResult.success) return seasonResult;
2599
+ let roundId;
2600
+ if (query.round != null) {
2601
+ const roundsResult = await this.client.resolveRounds(seasonResult.data);
2602
+ if (!roundsResult.success) return roundsResult;
2603
+ const round = roundsResult.data.find((r) => r.roundNumber === query.round);
2604
+ if (round) {
2605
+ roundId = round.id;
2606
+ }
2607
+ }
2608
+ const ladderResult = await this.client.fetchLadder(seasonResult.data, roundId);
2609
+ if (!ladderResult.success) return ladderResult;
2610
+ const firstLadder = ladderResult.data.ladders[0];
2611
+ const entries = firstLadder ? transformLadderEntries(firstLadder.entries) : [];
2612
+ return ok({
2613
+ season: query.season,
2614
+ roundNumber: ladderResult.data.round?.roundNumber ?? null,
2615
+ entries,
2616
+ competition
2617
+ });
2618
+ }
2619
+ };
2620
+
2621
+ // src/transforms/computed-ladder.ts
2622
+ function computeLadder(results, upToRound) {
2623
+ const teams = /* @__PURE__ */ new Map();
2624
+ const filtered = upToRound != null ? results.filter((r) => r.roundType === "HomeAndAway" && r.roundNumber <= upToRound) : results.filter((r) => r.roundType === "HomeAndAway");
2625
+ for (const match of filtered) {
2626
+ if (match.status !== "Complete") continue;
2627
+ if (match.homePoints === null || match.awayPoints === null) continue;
2628
+ const homePoints = match.homePoints;
2629
+ const awayPoints = match.awayPoints;
2630
+ const home = getOrCreate(teams, match.homeTeam);
2631
+ const away = getOrCreate(teams, match.awayTeam);
2632
+ home.played++;
2633
+ away.played++;
2634
+ home.pointsFor += homePoints;
2635
+ home.pointsAgainst += awayPoints;
2636
+ away.pointsFor += awayPoints;
2637
+ away.pointsAgainst += homePoints;
2638
+ if (homePoints > awayPoints) {
2639
+ home.wins++;
2640
+ away.losses++;
2641
+ } else if (awayPoints > homePoints) {
2642
+ away.wins++;
2643
+ home.losses++;
2644
+ } else {
2645
+ home.draws++;
2646
+ away.draws++;
2647
+ }
2648
+ }
2649
+ const entries = [...teams.entries()].map(([teamName, acc]) => {
2650
+ const percentage = acc.pointsAgainst === 0 ? 0 : acc.pointsFor / acc.pointsAgainst * 100;
2651
+ const premiershipsPoints = acc.wins * 4 + acc.draws * 2;
2652
+ return {
2653
+ position: 0,
2654
+ // filled below after sorting
2655
+ team: teamName,
2656
+ played: acc.played,
2657
+ wins: acc.wins,
2658
+ losses: acc.losses,
2659
+ draws: acc.draws,
2660
+ pointsFor: acc.pointsFor,
2661
+ pointsAgainst: acc.pointsAgainst,
2662
+ percentage,
2663
+ premiershipsPoints,
2664
+ form: null
2665
+ };
2666
+ });
2667
+ entries.sort((a, b) => {
2668
+ if (b.premiershipsPoints !== a.premiershipsPoints) {
2669
+ return b.premiershipsPoints - a.premiershipsPoints;
2670
+ }
2671
+ return b.percentage - a.percentage;
2672
+ });
2673
+ for (let i = 0; i < entries.length; i++) {
2674
+ const entry = entries[i];
2675
+ if (entry) {
2676
+ entries[i] = { ...entry, position: i + 1 };
2527
2677
  }
2528
2678
  }
2529
- return ok(fixtures);
2679
+ return entries;
2680
+ }
2681
+ function getOrCreate(map, team) {
2682
+ let acc = map.get(team);
2683
+ if (!acc) {
2684
+ acc = { played: 0, wins: 0, losses: 0, draws: 0, pointsFor: 0, pointsAgainst: 0 };
2685
+ map.set(team, acc);
2686
+ }
2687
+ return acc;
2530
2688
  }
2531
2689
 
2532
2690
  // src/sources/afl-tables.ts
@@ -3076,1043 +3234,1209 @@ function parseAflTablesPlayerList(html, teamName) {
3076
3234
  goals: goalsScored,
3077
3235
  draftYear: null,
3078
3236
  draftPosition: null,
3079
- draftType: null,
3080
- debutYear,
3081
- recruitedFrom: null
3082
- });
3083
- });
3084
- return players;
3085
- }
3086
-
3087
- // src/transforms/computed-ladder.ts
3088
- function computeLadder(results, upToRound) {
3089
- const teams = /* @__PURE__ */ new Map();
3090
- const filtered = upToRound != null ? results.filter((r) => r.roundType === "HomeAndAway" && r.roundNumber <= upToRound) : results.filter((r) => r.roundType === "HomeAndAway");
3091
- for (const match of filtered) {
3092
- if (match.status !== "Complete") continue;
3093
- const home = getOrCreate(teams, match.homeTeam);
3094
- const away = getOrCreate(teams, match.awayTeam);
3095
- home.played++;
3096
- away.played++;
3097
- home.pointsFor += match.homePoints;
3098
- home.pointsAgainst += match.awayPoints;
3099
- away.pointsFor += match.awayPoints;
3100
- away.pointsAgainst += match.homePoints;
3101
- if (match.homePoints > match.awayPoints) {
3102
- home.wins++;
3103
- away.losses++;
3104
- } else if (match.awayPoints > match.homePoints) {
3105
- away.wins++;
3106
- home.losses++;
3107
- } else {
3108
- home.draws++;
3109
- away.draws++;
3110
- }
3111
- }
3112
- const entries = [...teams.entries()].map(([teamName, acc]) => {
3113
- const percentage = acc.pointsAgainst === 0 ? 0 : acc.pointsFor / acc.pointsAgainst * 100;
3114
- const premiershipsPoints = acc.wins * 4 + acc.draws * 2;
3115
- return {
3116
- position: 0,
3117
- // filled below after sorting
3118
- team: teamName,
3119
- played: acc.played,
3120
- wins: acc.wins,
3121
- losses: acc.losses,
3122
- draws: acc.draws,
3123
- pointsFor: acc.pointsFor,
3124
- pointsAgainst: acc.pointsAgainst,
3125
- percentage,
3126
- premiershipsPoints,
3127
- form: null
3128
- };
3129
- });
3130
- entries.sort((a, b) => {
3131
- if (b.premiershipsPoints !== a.premiershipsPoints) {
3132
- return b.premiershipsPoints - a.premiershipsPoints;
3133
- }
3134
- return b.percentage - a.percentage;
3135
- });
3136
- for (let i = 0; i < entries.length; i++) {
3137
- const entry = entries[i];
3138
- if (entry) {
3139
- entries[i] = { ...entry, position: i + 1 };
3140
- }
3141
- }
3142
- return entries;
3143
- }
3144
- function getOrCreate(map, team) {
3145
- let acc = map.get(team);
3146
- if (!acc) {
3147
- acc = { played: 0, wins: 0, losses: 0, draws: 0, pointsFor: 0, pointsAgainst: 0 };
3148
- map.set(team, acc);
3149
- }
3150
- return acc;
3151
- }
3152
-
3153
- // src/transforms/ladder.ts
3154
- function transformLadderEntries(entries) {
3155
- return entries.map((entry) => {
3156
- const record = entry.thisSeasonRecord;
3157
- const wl = record?.winLossRecord;
3158
- return {
3159
- position: entry.position,
3160
- team: normaliseTeamName(entry.team.name),
3161
- played: entry.played ?? wl?.played ?? 0,
3162
- wins: wl?.wins ?? 0,
3163
- losses: wl?.losses ?? 0,
3164
- draws: wl?.draws ?? 0,
3165
- pointsFor: entry.pointsFor ?? 0,
3166
- pointsAgainst: entry.pointsAgainst ?? 0,
3167
- percentage: record?.percentage ?? 0,
3168
- premiershipsPoints: record?.aggregatePoints ?? 0,
3169
- form: entry.form ?? null
3170
- };
3237
+ draftType: null,
3238
+ debutYear,
3239
+ recruitedFrom: null
3240
+ });
3171
3241
  });
3242
+ return players;
3172
3243
  }
3173
3244
 
3174
- // src/api/ladder.ts
3175
- async function fetchLadder(query) {
3176
- const competition = query.competition ?? "AFLM";
3177
- if (query.source === "squiggle") {
3178
- if (competition === "AFLW") return err(aflwUnsupportedError("squiggle"));
3179
- const client2 = new SquiggleClient();
3180
- const result = await client2.fetchStandings(query.season, query.round ?? void 0);
3245
+ // src/sources/adapters/afl-tables.ts
3246
+ var AFL_TABLES_MATCH_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 1897 }]]);
3247
+ var AFL_TABLES_PLAYER_STATS_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 1965 }]]);
3248
+ var AFL_TABLES_TEAM_STATS_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 1965 }]]);
3249
+ var AFL_TABLES_LADDER_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 1897 }]]);
3250
+ var AFL_TABLES_SQUAD_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 1897 }]]);
3251
+ var AflTablesMatchSource = class {
3252
+ constructor(client = new AflTablesClient()) {
3253
+ this.client = client;
3254
+ }
3255
+ id = "afl-tables";
3256
+ coverage = AFL_TABLES_MATCH_COVERAGE;
3257
+ async fetchMatches(query) {
3258
+ const result = await this.client.fetchSeasonResults(query.season);
3259
+ if (!result.success) return result;
3260
+ const filtered = query.round != null ? result.data.filter((m) => m.roundNumber === query.round) : result.data;
3261
+ return ok(filtered);
3262
+ }
3263
+ };
3264
+ var AflTablesPlayerStatsSource = class {
3265
+ constructor(client = new AflTablesClient()) {
3266
+ this.client = client;
3267
+ }
3268
+ id = "afl-tables";
3269
+ coverage = AFL_TABLES_PLAYER_STATS_COVERAGE;
3270
+ async fetchPlayerStats(query) {
3271
+ const result = await this.client.fetchSeasonPlayerStats(query.season);
3272
+ if (!result.success) return result;
3273
+ if (query.round != null) {
3274
+ return ok(result.data.filter((s) => s.roundNumber === query.round));
3275
+ }
3276
+ return result;
3277
+ }
3278
+ };
3279
+ var AflTablesTeamStatsSource = class {
3280
+ constructor(client = new AflTablesClient()) {
3281
+ this.client = client;
3282
+ }
3283
+ id = "afl-tables";
3284
+ coverage = AFL_TABLES_TEAM_STATS_COVERAGE;
3285
+ async fetchTeamStats(query) {
3286
+ const summaryType = query.summaryType ?? "totals";
3287
+ const statsResult = await this.client.fetchTeamStats(query.season);
3288
+ if (!statsResult.success) return statsResult;
3289
+ const needsGp = statsResult.data.some((e) => e.gamesPlayed === 0);
3290
+ const gpMap = /* @__PURE__ */ new Map();
3291
+ if (needsGp) {
3292
+ const resultsResult = await this.client.fetchSeasonResults(query.season);
3293
+ if (resultsResult.success) {
3294
+ for (const m of resultsResult.data) {
3295
+ const home = normaliseTeamName(m.homeTeam);
3296
+ const away = normaliseTeamName(m.awayTeam);
3297
+ gpMap.set(home, (gpMap.get(home) ?? 0) + 1);
3298
+ gpMap.set(away, (gpMap.get(away) ?? 0) + 1);
3299
+ }
3300
+ }
3301
+ }
3302
+ const enriched = statsResult.data.map((entry) => ({
3303
+ ...entry,
3304
+ gamesPlayed: gpMap.get(normaliseTeamName(entry.team)) ?? entry.gamesPlayed
3305
+ }));
3306
+ if (summaryType === "averages") {
3307
+ return ok(
3308
+ enriched.map((entry) => ({
3309
+ ...entry,
3310
+ stats: Object.fromEntries(
3311
+ Object.entries(entry.stats).map(([k, v]) => [
3312
+ k,
3313
+ entry.gamesPlayed > 0 ? +(v / entry.gamesPlayed).toFixed(1) : 0
3314
+ ])
3315
+ )
3316
+ }))
3317
+ );
3318
+ }
3319
+ return ok(enriched);
3320
+ }
3321
+ };
3322
+ var AflTablesSquadSource = class {
3323
+ constructor(client = new AflTablesClient()) {
3324
+ this.client = client;
3325
+ }
3326
+ id = "afl-tables";
3327
+ coverage = AFL_TABLES_SQUAD_COVERAGE;
3328
+ async fetchSquad(query) {
3329
+ const competition = query.competition ?? "AFLM";
3330
+ const teamName = normaliseTeamName(query.team);
3331
+ const result = await this.client.fetchPlayerList(teamName);
3181
3332
  if (!result.success) return result;
3333
+ const players = result.data.map((p) => ({
3334
+ playerId: p.playerId,
3335
+ givenName: p.givenName,
3336
+ surname: p.surname,
3337
+ displayName: p.displayName,
3338
+ jumperNumber: p.jumperNumber,
3339
+ position: p.position,
3340
+ dateOfBirth: p.dateOfBirth ? parseDate(p.dateOfBirth) : null,
3341
+ heightCm: p.heightCm,
3342
+ weightKg: p.weightKg,
3343
+ draftYear: p.draftYear,
3344
+ draftPosition: p.draftPosition,
3345
+ draftType: p.draftType,
3346
+ debutYear: p.debutYear,
3347
+ recruitedFrom: p.recruitedFrom,
3348
+ gamesPlayed: p.gamesPlayed,
3349
+ goals: p.goals
3350
+ }));
3182
3351
  return ok({
3352
+ teamId: teamName,
3353
+ teamName,
3183
3354
  season: query.season,
3184
- roundNumber: query.round ?? null,
3185
- entries: transformSquiggleStandings(result.data.standings),
3355
+ players,
3186
3356
  competition
3187
3357
  });
3188
3358
  }
3189
- if (query.source === "afl-tables") {
3190
- if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
3191
- const atClient = new AflTablesClient();
3192
- const resultsResult = await atClient.fetchSeasonResults(query.season);
3359
+ };
3360
+ var AflTablesLadderSource = class {
3361
+ constructor(client = new AflTablesClient()) {
3362
+ this.client = client;
3363
+ }
3364
+ id = "afl-tables";
3365
+ coverage = AFL_TABLES_LADDER_COVERAGE;
3366
+ async fetchLadder(query) {
3367
+ const competition = query.competition ?? "AFLM";
3368
+ const resultsResult = await this.client.fetchSeasonResults(query.season);
3193
3369
  if (!resultsResult.success) return resultsResult;
3194
- const entries2 = computeLadder(resultsResult.data, query.round ?? void 0);
3370
+ const entries = computeLadder(resultsResult.data, query.round ?? void 0);
3195
3371
  return ok({
3196
3372
  season: query.season,
3197
3373
  roundNumber: query.round ?? null,
3198
- entries: entries2,
3374
+ entries,
3199
3375
  competition
3200
3376
  });
3201
3377
  }
3202
- if (query.source !== "afl-api") {
3203
- return err(
3204
- new UnsupportedSourceError(
3205
- "Ladder data is only available from the AFL API, AFL Tables, or Squiggle sources.",
3206
- query.source
3207
- )
3378
+ };
3379
+
3380
+ // src/sources/adapters/footywire.ts
3381
+ var FOOTYWIRE_MATCH_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 2010 }]]);
3382
+ var FOOTYWIRE_PLAYER_STATS_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 2010 }]]);
3383
+ var FOOTYWIRE_TEAM_STATS_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 2010 }]]);
3384
+ var FOOTYWIRE_SQUAD_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 2010 }]]);
3385
+ var FootyWireMatchSource = class {
3386
+ constructor(client = new FootyWireClient()) {
3387
+ this.client = client;
3388
+ }
3389
+ id = "footywire";
3390
+ coverage = FOOTYWIRE_MATCH_COVERAGE;
3391
+ async fetchMatches(query) {
3392
+ const result = await this.client.fetchSeasonFixture(query.season);
3393
+ if (!result.success) return result;
3394
+ const filtered = query.round != null ? result.data.filter((m) => m.roundNumber === query.round) : result.data;
3395
+ return ok(filtered);
3396
+ }
3397
+ };
3398
+ var FootyWirePlayerStatsSource = class {
3399
+ constructor(client = new FootyWireClient()) {
3400
+ this.client = client;
3401
+ }
3402
+ id = "footywire";
3403
+ coverage = FOOTYWIRE_PLAYER_STATS_COVERAGE;
3404
+ async fetchPlayerStats(query) {
3405
+ const idsResult = await this.client.fetchSeasonMatchIds(query.season);
3406
+ if (!idsResult.success) return idsResult;
3407
+ const entries = query.round != null ? idsResult.data.filter((e) => e.roundNumber === query.round) : idsResult.data;
3408
+ if (entries.length === 0) return ok([]);
3409
+ const results = await batchedMap(
3410
+ entries,
3411
+ (e) => this.client.fetchMatchPlayerStats(e.matchId, query.season, e.roundNumber),
3412
+ { batchSize: 5, delayMs: 500 }
3208
3413
  );
3414
+ const allStats = [];
3415
+ for (const result of results) {
3416
+ if (result.success) {
3417
+ allStats.push(...result.data);
3418
+ }
3419
+ }
3420
+ return ok(allStats);
3209
3421
  }
3210
- const client = new AflApiClient();
3211
- const seasonResult = await client.resolveCompSeason(competition, query.season);
3212
- if (!seasonResult.success) return seasonResult;
3213
- let roundId;
3214
- if (query.round != null) {
3215
- const roundsResult = await client.resolveRounds(seasonResult.data);
3216
- if (!roundsResult.success) return roundsResult;
3217
- const round = roundsResult.data.find((r) => r.roundNumber === query.round);
3218
- if (round) {
3219
- roundId = round.id;
3422
+ };
3423
+ var FootyWireSquadSource = class {
3424
+ constructor(client = new FootyWireClient()) {
3425
+ this.client = client;
3426
+ }
3427
+ id = "footywire";
3428
+ coverage = FOOTYWIRE_SQUAD_COVERAGE;
3429
+ async fetchSquad(query) {
3430
+ const competition = query.competition ?? "AFLM";
3431
+ const teamName = normaliseTeamName(query.team);
3432
+ const result = await this.client.fetchPlayerList(teamName);
3433
+ if (!result.success) return result;
3434
+ const players = result.data.map((p) => ({
3435
+ playerId: p.playerId,
3436
+ givenName: p.givenName,
3437
+ surname: p.surname,
3438
+ displayName: p.displayName,
3439
+ jumperNumber: p.jumperNumber,
3440
+ position: p.position,
3441
+ dateOfBirth: p.dateOfBirth ? parseDate(p.dateOfBirth) : null,
3442
+ heightCm: p.heightCm,
3443
+ weightKg: p.weightKg,
3444
+ draftYear: p.draftYear,
3445
+ draftPosition: p.draftPosition,
3446
+ draftType: p.draftType,
3447
+ debutYear: p.debutYear,
3448
+ recruitedFrom: p.recruitedFrom,
3449
+ gamesPlayed: p.gamesPlayed,
3450
+ goals: p.goals
3451
+ }));
3452
+ return ok({
3453
+ teamId: teamName,
3454
+ teamName,
3455
+ season: query.season,
3456
+ players,
3457
+ competition
3458
+ });
3459
+ }
3460
+ };
3461
+ var FootyWireTeamStatsSource = class {
3462
+ constructor(client = new FootyWireClient()) {
3463
+ this.client = client;
3464
+ }
3465
+ id = "footywire";
3466
+ coverage = FOOTYWIRE_TEAM_STATS_COVERAGE;
3467
+ async fetchTeamStats(query) {
3468
+ const summaryType = query.summaryType ?? "totals";
3469
+ return this.client.fetchTeamStats(query.season, summaryType);
3470
+ }
3471
+ };
3472
+
3473
+ // src/transforms/fryzigg-player-stats.ts
3474
+ var REQUIRED_COLUMN_GROUPS = [
3475
+ ["match_id"],
3476
+ ["match_date", "date"],
3477
+ ["player_id"],
3478
+ ["player_team", "team"]
3479
+ ];
3480
+ var AFLM_COLUMNS = {
3481
+ date: "match_date",
3482
+ homeTeam: "match_home_team",
3483
+ awayTeam: "match_away_team",
3484
+ team: "player_team",
3485
+ round: "match_round",
3486
+ jumperNumber: "guernsey_number",
3487
+ firstName: "player_first_name",
3488
+ lastName: "player_last_name",
3489
+ playerName: void 0,
3490
+ freesFor: "free_kicks_for",
3491
+ freesAgainst: "free_kicks_against",
3492
+ totalClearances: "clearances",
3493
+ inside50s: "inside_fifties",
3494
+ rebound50s: "rebounds",
3495
+ disposalEfficiency: "disposal_efficiency_percentage",
3496
+ marksInside50: "marks_inside_fifty",
3497
+ tacklesInside50: "tackles_inside_fifty",
3498
+ timeOnGround: "time_on_ground_percentage",
3499
+ position: "player_position",
3500
+ dreamTeamPoints: "afl_fantasy_score",
3501
+ totalPossessions: void 0
3502
+ };
3503
+ var AFLW_COLUMNS = {
3504
+ date: "date",
3505
+ homeTeam: "home_team",
3506
+ awayTeam: "away_team",
3507
+ team: "team",
3508
+ round: "fixture_round",
3509
+ jumperNumber: "number",
3510
+ firstName: void 0,
3511
+ lastName: void 0,
3512
+ playerName: "player_name",
3513
+ freesFor: "frees_for",
3514
+ freesAgainst: "frees_against",
3515
+ totalClearances: "total_clearances",
3516
+ inside50s: "inside50s",
3517
+ rebound50s: "rebound50s",
3518
+ disposalEfficiency: "disposal_efficiency",
3519
+ marksInside50: "marks_inside50",
3520
+ tacklesInside50: "tackles_inside50",
3521
+ timeOnGround: "time_on_ground",
3522
+ position: "position",
3523
+ dreamTeamPoints: "fantasy_score",
3524
+ totalPossessions: "total_possessions"
3525
+ };
3526
+ function transformFryziggPlayerStats(frame, options) {
3527
+ const colIndex = /* @__PURE__ */ new Map();
3528
+ for (let i = 0; i < frame.names.length; i++) {
3529
+ const name = frame.names[i];
3530
+ if (name !== void 0) {
3531
+ colIndex.set(name, i);
3532
+ }
3533
+ }
3534
+ for (const group of REQUIRED_COLUMN_GROUPS) {
3535
+ if (!group.some((name) => colIndex.has(name))) {
3536
+ return err(
3537
+ new ScrapeError(
3538
+ `Fryzigg data frame missing required column: "${group.join('" or "')}"`,
3539
+ "fryzigg"
3540
+ )
3541
+ );
3542
+ }
3543
+ }
3544
+ const getCol = (name) => {
3545
+ if (name === void 0) return void 0;
3546
+ const idx = colIndex.get(name);
3547
+ if (idx === void 0) return void 0;
3548
+ return frame.columns[idx];
3549
+ };
3550
+ const isAflw = options.competition === "AFLW";
3551
+ const mapping = isAflw ? AFLW_COLUMNS : AFLM_COLUMNS;
3552
+ const cols = {
3553
+ matchId: getCol("match_id"),
3554
+ date: getCol(mapping.date),
3555
+ homeTeam: getCol(mapping.homeTeam),
3556
+ awayTeam: getCol(mapping.awayTeam),
3557
+ team: getCol(mapping.team),
3558
+ round: getCol(mapping.round),
3559
+ jumperNumber: getCol(mapping.jumperNumber),
3560
+ playerId: getCol("player_id"),
3561
+ firstName: getCol(mapping.firstName),
3562
+ lastName: getCol(mapping.lastName),
3563
+ playerName: getCol(mapping.playerName),
3564
+ kicks: getCol("kicks"),
3565
+ handballs: getCol("handballs"),
3566
+ disposals: getCol("disposals"),
3567
+ marks: getCol("marks"),
3568
+ goals: getCol("goals"),
3569
+ behinds: getCol("behinds"),
3570
+ tackles: getCol("tackles"),
3571
+ hitouts: getCol("hitouts"),
3572
+ freesFor: getCol(mapping.freesFor),
3573
+ freesAgainst: getCol(mapping.freesAgainst),
3574
+ contestedPossessions: getCol("contested_possessions"),
3575
+ uncontestedPossessions: getCol("uncontested_possessions"),
3576
+ contestedMarks: getCol("contested_marks"),
3577
+ intercepts: getCol("intercepts"),
3578
+ centreClearances: getCol("centre_clearances"),
3579
+ stoppageClearances: getCol("stoppage_clearances"),
3580
+ totalClearances: getCol(mapping.totalClearances),
3581
+ inside50s: getCol(mapping.inside50s),
3582
+ rebound50s: getCol(mapping.rebound50s),
3583
+ clangers: getCol("clangers"),
3584
+ turnovers: getCol("turnovers"),
3585
+ onePercenters: getCol("one_percenters"),
3586
+ bounces: getCol("bounces"),
3587
+ goalAssists: getCol("goal_assists"),
3588
+ disposalEfficiency: getCol(mapping.disposalEfficiency),
3589
+ metresGained: getCol("metres_gained"),
3590
+ marksInside50: getCol(mapping.marksInside50),
3591
+ tacklesInside50: getCol(mapping.tacklesInside50),
3592
+ shotsAtGoal: getCol("shots_at_goal"),
3593
+ scoreInvolvements: getCol("score_involvements"),
3594
+ totalPossessions: getCol(mapping.totalPossessions),
3595
+ timeOnGround: getCol(mapping.timeOnGround),
3596
+ ratingPoints: getCol("rating_points"),
3597
+ position: getCol(mapping.position),
3598
+ brownlowVotes: getCol("brownlow_votes"),
3599
+ supercoachScore: getCol("supercoach_score"),
3600
+ dreamTeamPoints: getCol(mapping.dreamTeamPoints),
3601
+ effectiveDisposals: getCol("effective_disposals"),
3602
+ effectiveKicks: getCol("effective_kicks"),
3603
+ pressureActs: getCol("pressure_acts"),
3604
+ defHalfPressureActs: getCol("def_half_pressure_acts"),
3605
+ spoils: getCol("spoils"),
3606
+ hitoutsToAdvantage: getCol("hitouts_to_advantage"),
3607
+ hitoutWinPercentage: getCol("hitout_win_percentage"),
3608
+ groundBallGets: getCol("ground_ball_gets"),
3609
+ f50GroundBallGets: getCol("f50_ground_ball_gets"),
3610
+ interceptMarks: getCol("intercept_marks"),
3611
+ marksOnLead: getCol("marks_on_lead"),
3612
+ contestOffOneOnOnes: getCol("contest_off_one_on_ones"),
3613
+ contestOffWins: getCol("contest_off_wins"),
3614
+ contestDefOneOnOnes: getCol("contest_def_one_on_ones"),
3615
+ contestDefLosses: getCol("contest_def_losses"),
3616
+ ruckContests: getCol("ruck_contests"),
3617
+ scoreLaunches: getCol("score_launches")
3618
+ };
3619
+ const dateCol = cols.date;
3620
+ const roundCol = cols.round;
3621
+ const nRows = dateCol ? dateCol.length : 0;
3622
+ const hasFilters = options.season !== void 0 || options.round !== void 0;
3623
+ let rowIndices = null;
3624
+ let rowCount = nRows;
3625
+ if (hasFilters) {
3626
+ const matching = [];
3627
+ for (let i = 0; i < nRows; i++) {
3628
+ if (options.season !== void 0) {
3629
+ const dateStr = dateCol?.[i];
3630
+ if (typeof dateStr !== "string") continue;
3631
+ const year = Number(dateStr.slice(0, 4));
3632
+ if (year !== options.season) continue;
3633
+ }
3634
+ if (options.round !== void 0 && roundCol) {
3635
+ const roundVal = roundCol[i];
3636
+ const roundNum = typeof roundVal === "string" ? Number(roundVal) : roundVal;
3637
+ if (roundNum !== options.round) continue;
3638
+ }
3639
+ matching.push(i);
3220
3640
  }
3641
+ rowIndices = matching;
3642
+ rowCount = matching.length;
3643
+ }
3644
+ const stats = new Array(rowCount);
3645
+ for (let j = 0; j < rowCount; j++) {
3646
+ const i = rowIndices ? rowIndices[j] : j;
3647
+ stats[j] = mapRow(i, cols, isAflw, options.competition);
3648
+ }
3649
+ return ok(stats);
3650
+ }
3651
+ function numAt(column, i) {
3652
+ if (!column) return null;
3653
+ const v = column[i];
3654
+ return typeof v === "number" ? v : null;
3655
+ }
3656
+ function strAt(column, i) {
3657
+ if (!column) return null;
3658
+ const v = column[i];
3659
+ return typeof v === "string" ? v : null;
3660
+ }
3661
+ function roundAt(column, i) {
3662
+ if (!column) return 0;
3663
+ const v = column[i];
3664
+ if (typeof v === "number") return v;
3665
+ if (typeof v === "string") {
3666
+ const n = Number(v);
3667
+ return Number.isNaN(n) ? 0 : n;
3221
3668
  }
3222
- const ladderResult = await client.fetchLadder(seasonResult.data, roundId);
3223
- if (!ladderResult.success) return ladderResult;
3224
- const firstLadder = ladderResult.data.ladders[0];
3225
- const entries = firstLadder ? transformLadderEntries(firstLadder.entries) : [];
3226
- return ok({
3227
- season: query.season,
3228
- roundNumber: ladderResult.data.round?.roundNumber ?? null,
3229
- entries,
3230
- competition
3231
- });
3669
+ return 0;
3232
3670
  }
3233
-
3234
- // src/transforms/lineup.ts
3235
- var EMERGENCY_POSITIONS = /* @__PURE__ */ new Set(["EMG", "EMERG"]);
3236
- var SUBSTITUTE_POSITIONS = /* @__PURE__ */ new Set(["SUB", "INT"]);
3237
- function transformMatchRoster(roster, season, roundNumber, competition) {
3238
- const homeTeamId = roster.match.homeTeamId;
3239
- const awayTeamId = roster.match.awayTeamId;
3240
- const homeTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === homeTeamId);
3241
- const awayTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === awayTeamId);
3242
- const mapPlayers = (players) => players.map((p) => {
3243
- const inner = p.player.player;
3244
- const position = p.player.position ?? null;
3245
- return {
3246
- playerId: inner.playerId,
3247
- givenName: inner.playerName.givenName,
3248
- surname: inner.playerName.surname,
3249
- displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
3250
- jumperNumber: p.jumperNumber ?? null,
3251
- position,
3252
- isEmergency: position !== null && EMERGENCY_POSITIONS.has(position),
3253
- isSubstitute: position !== null && SUBSTITUTE_POSITIONS.has(position)
3254
- };
3255
- });
3671
+ function mapRow(i, c, isAflw, competition) {
3672
+ const dateStr = strAt(c.date, i);
3673
+ let firstName;
3674
+ let lastName;
3675
+ if (isAflw) {
3676
+ const playerName = strAt(c.playerName, i) ?? "";
3677
+ const commaIdx = playerName.indexOf(", ");
3678
+ firstName = commaIdx >= 0 ? playerName.slice(0, commaIdx) : playerName;
3679
+ lastName = commaIdx >= 0 ? playerName.slice(commaIdx + 2) : "";
3680
+ } else {
3681
+ firstName = strAt(c.firstName, i) ?? "";
3682
+ lastName = strAt(c.lastName, i) ?? "";
3683
+ }
3684
+ const team = strAt(c.team, i) ?? "";
3685
+ const homeTeam = strAt(c.homeTeam, i);
3686
+ const awayTeam = strAt(c.awayTeam, i);
3256
3687
  return {
3257
- matchId: roster.match.matchId,
3258
- season,
3259
- roundNumber,
3260
- homeTeam: normaliseTeamName(roster.match.homeTeam.name),
3261
- awayTeam: normaliseTeamName(roster.match.awayTeam.name),
3262
- homePlayers: homeTeamPlayers ? mapPlayers(homeTeamPlayers.players) : [],
3263
- awayPlayers: awayTeamPlayers ? mapPlayers(awayTeamPlayers.players) : [],
3264
- competition
3688
+ matchId: String(c.matchId?.[i] ?? ""),
3689
+ season: dateStr ? Number(dateStr.slice(0, 4)) : 0,
3690
+ roundNumber: roundAt(c.round, i),
3691
+ team: normaliseTeamName(team),
3692
+ competition,
3693
+ date: dateStr ? parseDate(dateStr) : null,
3694
+ homeTeam: homeTeam ? normaliseTeamName(homeTeam) : null,
3695
+ awayTeam: awayTeam ? normaliseTeamName(awayTeam) : null,
3696
+ playerId: String(c.playerId?.[i] ?? ""),
3697
+ givenName: firstName,
3698
+ surname: lastName,
3699
+ displayName: `${firstName} ${lastName}`.trim(),
3700
+ jumperNumber: numAt(c.jumperNumber, i),
3701
+ kicks: numAt(c.kicks, i),
3702
+ handballs: numAt(c.handballs, i),
3703
+ disposals: numAt(c.disposals, i),
3704
+ marks: numAt(c.marks, i),
3705
+ goals: numAt(c.goals, i),
3706
+ behinds: numAt(c.behinds, i),
3707
+ tackles: numAt(c.tackles, i),
3708
+ hitouts: numAt(c.hitouts, i),
3709
+ freesFor: numAt(c.freesFor, i),
3710
+ freesAgainst: numAt(c.freesAgainst, i),
3711
+ contestedPossessions: numAt(c.contestedPossessions, i),
3712
+ uncontestedPossessions: numAt(c.uncontestedPossessions, i),
3713
+ contestedMarks: numAt(c.contestedMarks, i),
3714
+ intercepts: numAt(c.intercepts, i),
3715
+ centreClearances: numAt(c.centreClearances, i),
3716
+ stoppageClearances: numAt(c.stoppageClearances, i),
3717
+ totalClearances: numAt(c.totalClearances, i),
3718
+ inside50s: numAt(c.inside50s, i),
3719
+ rebound50s: numAt(c.rebound50s, i),
3720
+ clangers: numAt(c.clangers, i),
3721
+ turnovers: numAt(c.turnovers, i),
3722
+ onePercenters: numAt(c.onePercenters, i),
3723
+ bounces: numAt(c.bounces, i),
3724
+ goalAssists: numAt(c.goalAssists, i),
3725
+ disposalEfficiency: numAt(c.disposalEfficiency, i),
3726
+ metresGained: numAt(c.metresGained, i),
3727
+ goalAccuracy: null,
3728
+ marksInside50: numAt(c.marksInside50, i),
3729
+ tacklesInside50: numAt(c.tacklesInside50, i),
3730
+ shotsAtGoal: numAt(c.shotsAtGoal, i),
3731
+ scoreInvolvements: numAt(c.scoreInvolvements, i),
3732
+ totalPossessions: numAt(c.totalPossessions, i),
3733
+ timeOnGroundPercentage: numAt(c.timeOnGround, i),
3734
+ ratingPoints: numAt(c.ratingPoints, i),
3735
+ position: strAt(c.position, i),
3736
+ goalEfficiency: null,
3737
+ shotEfficiency: null,
3738
+ interchangeCounts: null,
3739
+ brownlowVotes: numAt(c.brownlowVotes, i),
3740
+ supercoachScore: numAt(c.supercoachScore, i),
3741
+ dreamTeamPoints: numAt(c.dreamTeamPoints, i),
3742
+ effectiveDisposals: numAt(c.effectiveDisposals, i),
3743
+ effectiveKicks: numAt(c.effectiveKicks, i),
3744
+ kickEfficiency: null,
3745
+ kickToHandballRatio: null,
3746
+ pressureActs: numAt(c.pressureActs, i),
3747
+ defHalfPressureActs: numAt(c.defHalfPressureActs, i),
3748
+ spoils: numAt(c.spoils, i),
3749
+ hitoutsToAdvantage: numAt(c.hitoutsToAdvantage, i),
3750
+ hitoutWinPercentage: numAt(c.hitoutWinPercentage, i),
3751
+ hitoutToAdvantageRate: null,
3752
+ groundBallGets: numAt(c.groundBallGets, i),
3753
+ f50GroundBallGets: numAt(c.f50GroundBallGets, i),
3754
+ interceptMarks: numAt(c.interceptMarks, i),
3755
+ marksOnLead: numAt(c.marksOnLead, i),
3756
+ contestedPossessionRate: null,
3757
+ contestOffOneOnOnes: numAt(c.contestOffOneOnOnes, i),
3758
+ contestOffWins: numAt(c.contestOffWins, i),
3759
+ contestOffWinsPercentage: null,
3760
+ contestDefOneOnOnes: numAt(c.contestDefOneOnOnes, i),
3761
+ contestDefLosses: numAt(c.contestDefLosses, i),
3762
+ contestDefLossPercentage: null,
3763
+ centreBounceAttendances: null,
3764
+ kickins: null,
3765
+ kickinsPlayon: null,
3766
+ ruckContests: numAt(c.ruckContests, i),
3767
+ scoreLaunches: numAt(c.scoreLaunches, i),
3768
+ source: "fryzigg"
3265
3769
  };
3266
3770
  }
3267
3771
 
3268
- // src/api/lineup.ts
3269
- async function fetchLineup(query) {
3270
- const competition = query.competition ?? "AFLM";
3271
- if (query.source !== "afl-api") {
3272
- return err(
3273
- new UnsupportedSourceError(
3274
- "Lineup data is only available from the AFL API source.",
3275
- query.source
3276
- )
3277
- );
3278
- }
3279
- const client = new AflApiClient();
3280
- if (query.matchId) {
3281
- const rosterResult = await client.fetchMatchRoster(query.matchId);
3282
- if (!rosterResult.success) return rosterResult;
3283
- return ok([transformMatchRoster(rosterResult.data, query.season, query.round, competition)]);
3284
- }
3285
- const seasonResult = await client.resolveCompSeason(competition, query.season);
3286
- if (!seasonResult.success) return seasonResult;
3287
- const matchItems = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
3288
- if (!matchItems.success) return matchItems;
3289
- if (matchItems.data.length === 0) {
3290
- return err(new AflApiError(`No matches found for round ${query.round}`));
3291
- }
3292
- const rosterResults = await batchedMap(
3293
- matchItems.data,
3294
- (item) => client.fetchMatchRoster(item.match.matchId)
3295
- );
3296
- const lineups = [];
3297
- for (const rosterResult of rosterResults) {
3298
- if (!rosterResult.success) return rosterResult;
3299
- lineups.push(transformMatchRoster(rosterResult.data, query.season, query.round, competition));
3772
+ // src/sources/fryzigg.ts
3773
+ import { isDataFrame, parseRds, RdsError } from "@jackemcpherson/rds-js";
3774
+ var FRYZIGG_URLS = {
3775
+ AFLM: "http://www.fryziggafl.net/static/fryziggafl.rds",
3776
+ AFLW: "http://www.fryziggafl.net/static/aflw_player_stats.rds"
3777
+ };
3778
+ var USER_AGENT2 = "fitzRoy-ts/1.0 (https://github.com/jackemcpherson/fitzRoy-ts)";
3779
+ var FryziggClient = class {
3780
+ fetchFn;
3781
+ constructor(options) {
3782
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch.bind(globalThis);
3300
3783
  }
3301
- return ok(lineups);
3302
- }
3303
-
3304
- // src/api/match-results.ts
3305
- async function fetchMatchResults(query) {
3306
- const competition = query.competition ?? "AFLM";
3307
- switch (query.source) {
3308
- case "afl-api": {
3309
- const client = new AflApiClient();
3310
- const seasonResult = await client.resolveCompSeason(competition, query.season);
3311
- if (!seasonResult.success) return seasonResult;
3312
- if (query.round != null) {
3313
- const itemsResult2 = await client.fetchRoundMatchItemsByNumber(
3314
- seasonResult.data,
3315
- query.round
3784
+ /**
3785
+ * Fetch the full player statistics dataset for a competition.
3786
+ *
3787
+ * Returns column-major DataFrame from rds-js. The caller is responsible
3788
+ * for filtering rows and mapping to domain types.
3789
+ *
3790
+ * @param competition - AFLM or AFLW.
3791
+ * @returns Column-major DataFrame with all rows, or an error.
3792
+ */
3793
+ async fetchPlayerStats(competition) {
3794
+ const url = FRYZIGG_URLS[competition];
3795
+ if (!url) {
3796
+ return err(new ScrapeError(`Fryzigg does not publish ${competition} data`, "fryzigg"));
3797
+ }
3798
+ try {
3799
+ const response = await this.fetchFn(url, {
3800
+ headers: { "User-Agent": USER_AGENT2 }
3801
+ });
3802
+ if (!response.ok) {
3803
+ return err(
3804
+ new ScrapeError(`Fryzigg request failed: ${response.status} (${url})`, "fryzigg")
3316
3805
  );
3317
- if (!itemsResult2.success) return itemsResult2;
3318
- return ok(transformMatchItems(itemsResult2.data, query.season, competition));
3319
3806
  }
3320
- const itemsResult = await client.fetchSeasonMatchItems(seasonResult.data);
3321
- if (!itemsResult.success) return itemsResult;
3322
- return ok(transformMatchItems(itemsResult.data, query.season, competition));
3323
- }
3324
- case "footywire": {
3325
- if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
3326
- const client = new FootyWireClient();
3327
- const result = await client.fetchSeasonResults(query.season);
3328
- if (!result.success) return result;
3329
- if (query.round != null) {
3330
- return ok(result.data.filter((m) => m.roundNumber === query.round));
3807
+ const buffer = new Uint8Array(await response.arrayBuffer());
3808
+ const result = await parseRds(buffer);
3809
+ if (!isDataFrame(result)) {
3810
+ return err(new ScrapeError("Fryzigg RDS file did not contain a data frame", "fryzigg"));
3331
3811
  }
3332
- return result;
3333
- }
3334
- case "afl-tables": {
3335
- if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
3336
- const client = new AflTablesClient();
3337
- const result = await client.fetchSeasonResults(query.season);
3338
- if (!result.success) return result;
3339
- if (query.round != null) {
3340
- return ok(result.data.filter((m) => m.roundNumber === query.round));
3812
+ return ok(result);
3813
+ } catch (cause) {
3814
+ if (cause instanceof RdsError) {
3815
+ return err(new ScrapeError(`Fryzigg RDS parse error: ${cause.message}`, "fryzigg"));
3341
3816
  }
3342
- return result;
3343
- }
3344
- case "squiggle": {
3345
- if (competition === "AFLW") return err(aflwUnsupportedError("squiggle"));
3346
- const client = new SquiggleClient();
3347
- const result = await client.fetchGames(query.season, query.round ?? void 0, 100);
3348
- if (!result.success) return result;
3349
- return ok(transformSquiggleGamesToResults(result.data.games, query.season));
3817
+ return err(
3818
+ new ScrapeError(
3819
+ `Fryzigg request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
3820
+ "fryzigg"
3821
+ )
3822
+ );
3350
3823
  }
3351
- default:
3352
- return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
3353
3824
  }
3354
- }
3825
+ };
3355
3826
 
3356
- // src/api/player-details.ts
3357
- async function resolveTeamId(client, teamName, competition) {
3358
- const teamType = competition === "AFLW" ? "WOMEN" : "MEN";
3359
- const result = await client.fetchTeams(teamType);
3360
- if (!result.success) return result;
3361
- const normalised = normaliseTeamName(teamName);
3362
- const match = result.data.find((t) => normaliseTeamName(t.name) === normalised);
3363
- if (!match) {
3364
- return err(new ValidationError(`Team not found: ${teamName}`));
3827
+ // src/sources/adapters/fryzigg.ts
3828
+ var FRYZIGG_PLAYER_STATS_COVERAGE = /* @__PURE__ */ new Map([
3829
+ ["AFLM", { minSeason: 2012 }],
3830
+ ["AFLW", { minSeason: 2017 }]
3831
+ ]);
3832
+ var FryziggPlayerStatsSource = class {
3833
+ constructor(client = new FryziggClient()) {
3834
+ this.client = client;
3365
3835
  }
3366
- return ok(String(match.id));
3367
- }
3368
- function mapSquadToPlayerDetails(data, fallbackTeamName, competition) {
3369
- const resolvedName = normaliseTeamName(data.squad.team?.name ?? fallbackTeamName);
3370
- return data.squad.players.map((p) => ({
3371
- playerId: p.player.providerId ?? String(p.player.id),
3372
- givenName: p.player.firstName,
3373
- surname: p.player.surname,
3374
- displayName: `${p.player.firstName} ${p.player.surname}`,
3375
- team: resolvedName,
3376
- jumperNumber: p.jumperNumber ?? null,
3377
- position: p.position ?? null,
3378
- dateOfBirth: p.player.dateOfBirth ?? null,
3379
- heightCm: p.player.heightInCm || null,
3380
- weightKg: p.player.weightInKg || null,
3381
- gamesPlayed: null,
3382
- goals: null,
3383
- draftYear: p.player.draftYear ? Number.parseInt(p.player.draftYear, 10) || null : null,
3384
- draftPosition: p.player.draftPosition ? Number.parseInt(p.player.draftPosition, 10) || null : null,
3385
- draftType: p.player.draftType ?? null,
3386
- debutYear: p.player.debutYear ? Number.parseInt(p.player.debutYear, 10) || null : null,
3387
- recruitedFrom: p.player.recruitedFrom ?? null,
3388
- source: "afl-api",
3389
- competition
3390
- }));
3836
+ id = "fryzigg";
3837
+ coverage = FRYZIGG_PLAYER_STATS_COVERAGE;
3838
+ async fetchPlayerStats(query) {
3839
+ const competition = query.competition ?? "AFLM";
3840
+ const result = await this.client.fetchPlayerStats(competition);
3841
+ if (!result.success) return result;
3842
+ return transformFryziggPlayerStats(result.data, {
3843
+ competition,
3844
+ season: query.season,
3845
+ round: query.round
3846
+ });
3847
+ }
3848
+ };
3849
+
3850
+ // src/sources/adapters/registry.ts
3851
+ var CapabilityRegistry = class {
3852
+ constructor(defaultSource) {
3853
+ this.defaultSource = defaultSource;
3854
+ }
3855
+ adapters = /* @__PURE__ */ new Map();
3856
+ register(adapter) {
3857
+ this.adapters.set(adapter.id, adapter);
3858
+ }
3859
+ get(id) {
3860
+ return this.adapters.get(id);
3861
+ }
3862
+ list() {
3863
+ return [...this.adapters.keys()];
3864
+ }
3865
+ all() {
3866
+ return [...this.adapters.values()];
3867
+ }
3868
+ };
3869
+ var matchRegistry = new CapabilityRegistry("afl-api");
3870
+ var playerStatsRegistry = new CapabilityRegistry("afl-api");
3871
+ var teamStatsRegistry = new CapabilityRegistry("afl-tables");
3872
+ var squadRegistry = new CapabilityRegistry("afl-api");
3873
+ var lineupRegistry = new CapabilityRegistry("afl-api");
3874
+ var ladderRegistry = new CapabilityRegistry("afl-api");
3875
+
3876
+ // src/transforms/squiggle.ts
3877
+ function toMatchStatus2(complete) {
3878
+ if (complete === 100) return "Complete";
3879
+ if (complete > 0) return "Live";
3880
+ return "Upcoming";
3391
3881
  }
3392
- async function fetchFromAflApi(query) {
3393
- const client = new AflApiClient();
3394
- const competition = query.competition ?? "AFLM";
3395
- const season = query.season ?? resolveDefaultSeason(competition);
3396
- const seasonResult = await client.resolveCompSeason(competition, season);
3397
- if (!seasonResult.success) return seasonResult;
3398
- if (query.team) {
3399
- const teamIdResult = await resolveTeamId(client, query.team, competition);
3400
- if (!teamIdResult.success) return teamIdResult;
3401
- const teamId = Number.parseInt(teamIdResult.data, 10);
3402
- if (Number.isNaN(teamId)) {
3403
- return err(new ValidationError(`Invalid team ID: ${teamIdResult.data}`));
3404
- }
3405
- const squadResult = await client.fetchSquad(teamId, seasonResult.data);
3406
- if (!squadResult.success) return squadResult;
3407
- return ok(mapSquadToPlayerDetails(squadResult.data, query.team, competition));
3408
- }
3409
- const teamType = competition === "AFLW" ? "WOMEN" : "MEN";
3410
- const teamsResult = await client.fetchTeams(teamType);
3411
- if (!teamsResult.success) return teamsResult;
3412
- const teamEntries = teamsResult.data.map((t) => ({
3413
- id: Number.parseInt(String(t.id), 10),
3414
- name: normaliseTeamName(t.name)
3882
+ function transformSquiggleGamesToResults(games, season) {
3883
+ return games.filter((g) => g.complete === 100).map((g) => ({
3884
+ matchId: `SQ_${g.id}`,
3885
+ season,
3886
+ roundNumber: g.round,
3887
+ roundType: inferRoundType(g.roundname),
3888
+ roundName: g.roundname || null,
3889
+ date: parseDate(g.unixtime) ?? new Date(g.unixtime * 1e3),
3890
+ venue: normaliseVenueName(g.venue),
3891
+ homeTeam: normaliseTeamName(g.hteam),
3892
+ awayTeam: normaliseTeamName(g.ateam),
3893
+ homeGoals: g.hgoals ?? 0,
3894
+ homeBehinds: g.hbehinds ?? 0,
3895
+ homePoints: g.hscore ?? 0,
3896
+ awayGoals: g.agoals ?? 0,
3897
+ awayBehinds: g.abehinds ?? 0,
3898
+ awayPoints: g.ascore ?? 0,
3899
+ margin: (g.hscore ?? 0) - (g.ascore ?? 0),
3900
+ q1Home: null,
3901
+ q2Home: null,
3902
+ q3Home: null,
3903
+ q4Home: null,
3904
+ q1Away: null,
3905
+ q2Away: null,
3906
+ q3Away: null,
3907
+ q4Away: null,
3908
+ status: "Complete",
3909
+ attendance: null,
3910
+ weatherTempCelsius: null,
3911
+ weatherType: null,
3912
+ roundCode: toRoundCode(g.roundname),
3913
+ venueState: null,
3914
+ venueTimezone: g.tz || null,
3915
+ homeRushedBehinds: null,
3916
+ awayRushedBehinds: null,
3917
+ homeMinutesInFront: null,
3918
+ awayMinutesInFront: null,
3919
+ source: "squiggle",
3920
+ competition: "AFLM"
3415
3921
  }));
3416
- const results = await batchedMap(
3417
- teamEntries,
3418
- (entry) => client.fetchSquad(entry.id, seasonResult.data)
3419
- );
3420
- const allPlayers = [];
3421
- for (let i = 0; i < results.length; i++) {
3422
- const result = results[i];
3423
- const entry = teamEntries[i];
3424
- if (result?.success && entry) {
3425
- allPlayers.push(...mapSquadToPlayerDetails(result.data, entry.name, competition));
3426
- }
3427
- }
3428
- return ok(allPlayers);
3429
3922
  }
3430
- async function fetchAllTeamsFromScraper(fetchFn, source, competition) {
3431
- const teamNames = [...AFL_SENIOR_TEAMS];
3432
- const results = await batchedMap(teamNames, (name) => fetchFn(name));
3433
- const allPlayers = [];
3434
- for (const result of results) {
3435
- if (result.success) {
3436
- allPlayers.push(...result.data.map((p) => ({ ...p, source, competition })));
3437
- }
3438
- }
3439
- return ok(allPlayers);
3440
- }
3441
- async function fetchFromFootyWire(query) {
3442
- const competition = query.competition ?? "AFLM";
3443
- if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
3444
- const client = new FootyWireClient();
3445
- if (query.team) {
3446
- const teamName = normaliseTeamName(query.team);
3447
- const result = await client.fetchPlayerList(teamName);
3448
- if (!result.success) return result;
3449
- return ok(result.data.map((p) => ({ ...p, source: "footywire", competition })));
3450
- }
3451
- return fetchAllTeamsFromScraper((name) => client.fetchPlayerList(name), "footywire", competition);
3452
- }
3453
- async function fetchFromAflTables(query) {
3454
- const competition = query.competition ?? "AFLM";
3455
- if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
3456
- const client = new AflTablesClient();
3457
- if (query.team) {
3458
- const teamName = normaliseTeamName(query.team);
3459
- const result = await client.fetchPlayerList(teamName);
3460
- if (!result.success) return result;
3461
- return ok(result.data.map((p) => ({ ...p, source: "afl-tables", competition })));
3462
- }
3463
- return fetchAllTeamsFromScraper(
3464
- (name) => client.fetchPlayerList(name),
3465
- "afl-tables",
3466
- competition
3467
- );
3923
+ function transformSquiggleGamesToFixture(games, season) {
3924
+ return games.map((g) => {
3925
+ const status = toMatchStatus2(g.complete);
3926
+ const isComplete = status === "Complete";
3927
+ return {
3928
+ matchId: `SQ_${g.id}`,
3929
+ season,
3930
+ roundNumber: g.round,
3931
+ roundType: inferRoundType(g.roundname),
3932
+ roundName: g.roundname || null,
3933
+ date: parseDate(g.unixtime) ?? new Date(g.unixtime * 1e3),
3934
+ venue: normaliseVenueName(g.venue),
3935
+ homeTeam: normaliseTeamName(g.hteam),
3936
+ awayTeam: normaliseTeamName(g.ateam),
3937
+ homeGoals: isComplete ? g.hgoals ?? 0 : null,
3938
+ homeBehinds: isComplete ? g.hbehinds ?? 0 : null,
3939
+ homePoints: isComplete ? g.hscore ?? 0 : null,
3940
+ awayGoals: isComplete ? g.agoals ?? 0 : null,
3941
+ awayBehinds: isComplete ? g.abehinds ?? 0 : null,
3942
+ awayPoints: isComplete ? g.ascore ?? 0 : null,
3943
+ margin: isComplete ? (g.hscore ?? 0) - (g.ascore ?? 0) : null,
3944
+ q1Home: null,
3945
+ q2Home: null,
3946
+ q3Home: null,
3947
+ q4Home: null,
3948
+ q1Away: null,
3949
+ q2Away: null,
3950
+ q3Away: null,
3951
+ q4Away: null,
3952
+ status,
3953
+ attendance: null,
3954
+ weatherTempCelsius: null,
3955
+ weatherType: null,
3956
+ roundCode: toRoundCode(g.roundname),
3957
+ venueState: null,
3958
+ venueTimezone: g.tz || null,
3959
+ homeRushedBehinds: null,
3960
+ awayRushedBehinds: null,
3961
+ homeMinutesInFront: null,
3962
+ awayMinutesInFront: null,
3963
+ source: "squiggle",
3964
+ competition: "AFLM"
3965
+ };
3966
+ });
3468
3967
  }
3469
- async function fetchPlayerDetails(query) {
3470
- switch (query.source) {
3471
- case "afl-api":
3472
- return fetchFromAflApi(query);
3473
- case "footywire":
3474
- return fetchFromFootyWire(query);
3475
- case "afl-tables":
3476
- return fetchFromAflTables(query);
3477
- default:
3478
- return err(
3479
- new UnsupportedSourceError(
3480
- `Source "${query.source}" is not supported for player details. Use "afl-api", "footywire", or "afl-tables".`,
3481
- query.source
3482
- )
3483
- );
3484
- }
3968
+ function transformSquiggleStandings(standings) {
3969
+ return standings.map((s) => ({
3970
+ position: s.rank,
3971
+ team: normaliseTeamName(s.name),
3972
+ played: s.played,
3973
+ wins: s.wins,
3974
+ losses: s.losses,
3975
+ draws: s.draws,
3976
+ pointsFor: s.for,
3977
+ pointsAgainst: s.against,
3978
+ percentage: s.percentage,
3979
+ premiershipsPoints: s.pts,
3980
+ form: null
3981
+ }));
3485
3982
  }
3486
3983
 
3487
- // src/sources/fryzigg.ts
3488
- import { isDataFrame, parseRds, RdsError } from "@jackemcpherson/rds-js";
3489
- var FRYZIGG_URLS = {
3490
- AFLM: "http://www.fryziggafl.net/static/fryziggafl.rds",
3491
- AFLW: "http://www.fryziggafl.net/static/aflw_player_stats.rds"
3492
- };
3984
+ // src/lib/squiggle-validation.ts
3985
+ import { z as z2 } from "zod";
3986
+ var SquiggleGameSchema = z2.object({
3987
+ id: z2.number(),
3988
+ year: z2.number(),
3989
+ round: z2.number(),
3990
+ roundname: z2.string(),
3991
+ hteam: z2.string(),
3992
+ ateam: z2.string(),
3993
+ hteamid: z2.number(),
3994
+ ateamid: z2.number(),
3995
+ hscore: z2.number().nullable(),
3996
+ ascore: z2.number().nullable(),
3997
+ hgoals: z2.number().nullable(),
3998
+ agoals: z2.number().nullable(),
3999
+ hbehinds: z2.number().nullable(),
4000
+ abehinds: z2.number().nullable(),
4001
+ winner: z2.string().nullable(),
4002
+ winnerteamid: z2.number().nullable(),
4003
+ venue: z2.string(),
4004
+ date: z2.string(),
4005
+ localtime: z2.string(),
4006
+ tz: z2.string(),
4007
+ unixtime: z2.number(),
4008
+ timestr: z2.string().nullable(),
4009
+ complete: z2.number(),
4010
+ is_final: z2.number(),
4011
+ is_grand_final: z2.number(),
4012
+ updated: z2.string()
4013
+ });
4014
+ var SquiggleGamesResponseSchema = z2.object({
4015
+ games: z2.array(SquiggleGameSchema)
4016
+ });
4017
+ var SquiggleStandingSchema = z2.object({
4018
+ id: z2.number(),
4019
+ name: z2.string(),
4020
+ rank: z2.number(),
4021
+ played: z2.number(),
4022
+ wins: z2.number(),
4023
+ losses: z2.number(),
4024
+ draws: z2.number(),
4025
+ pts: z2.number(),
4026
+ for: z2.number(),
4027
+ against: z2.number(),
4028
+ percentage: z2.number(),
4029
+ goals_for: z2.number(),
4030
+ goals_against: z2.number(),
4031
+ behinds_for: z2.number(),
4032
+ behinds_against: z2.number()
4033
+ });
4034
+ var SquiggleStandingsResponseSchema = z2.object({
4035
+ standings: z2.array(SquiggleStandingSchema)
4036
+ });
4037
+
4038
+ // src/sources/squiggle.ts
4039
+ var SQUIGGLE_BASE = "https://api.squiggle.com.au/";
3493
4040
  var USER_AGENT3 = "fitzRoy-ts/1.0 (https://github.com/jackemcpherson/fitzRoy-ts)";
3494
- var FryziggClient = class {
4041
+ var SquiggleClient = class {
3495
4042
  fetchFn;
3496
4043
  constructor(options) {
3497
4044
  this.fetchFn = options?.fetchFn ?? globalThis.fetch.bind(globalThis);
3498
4045
  }
3499
4046
  /**
3500
- * Fetch the full player statistics dataset for a competition.
3501
- *
3502
- * Returns column-major DataFrame from rds-js. The caller is responsible
3503
- * for filtering rows and mapping to domain types.
3504
- *
3505
- * @param competition - AFLM or AFLW.
3506
- * @returns Column-major DataFrame with all rows, or an error.
4047
+ * Fetch JSON from the Squiggle API.
3507
4048
  */
3508
- async fetchPlayerStats(competition) {
3509
- const url = FRYZIGG_URLS[competition];
4049
+ async fetchJson(params) {
4050
+ const url = `${SQUIGGLE_BASE}?${params.toString()}`;
3510
4051
  try {
3511
4052
  const response = await this.fetchFn(url, {
3512
4053
  headers: { "User-Agent": USER_AGENT3 }
3513
4054
  });
3514
4055
  if (!response.ok) {
3515
4056
  return err(
3516
- new ScrapeError(`Fryzigg request failed: ${response.status} (${url})`, "fryzigg")
4057
+ new ScrapeError(`Squiggle request failed: ${response.status} (${url})`, "squiggle")
3517
4058
  );
3518
4059
  }
3519
- const buffer = new Uint8Array(await response.arrayBuffer());
3520
- const result = await parseRds(buffer);
3521
- if (!isDataFrame(result)) {
3522
- return err(new ScrapeError("Fryzigg RDS file did not contain a data frame", "fryzigg"));
3523
- }
3524
- return ok(result);
4060
+ const json = await response.json();
4061
+ return ok(json);
3525
4062
  } catch (cause) {
3526
- if (cause instanceof RdsError) {
3527
- return err(new ScrapeError(`Fryzigg RDS parse error: ${cause.message}`, "fryzigg"));
3528
- }
3529
4063
  return err(
3530
4064
  new ScrapeError(
3531
- `Fryzigg request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
3532
- "fryzigg"
4065
+ `Squiggle request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
4066
+ "squiggle"
3533
4067
  )
3534
4068
  );
3535
4069
  }
3536
4070
  }
3537
- };
3538
-
3539
- // src/transforms/fryzigg-player-stats.ts
3540
- var REQUIRED_COLUMN_GROUPS = [
3541
- ["match_id"],
3542
- ["match_date", "date"],
3543
- ["player_id"],
3544
- ["player_team", "team"]
3545
- ];
3546
- var AFLM_COLUMNS = {
3547
- date: "match_date",
3548
- homeTeam: "match_home_team",
3549
- awayTeam: "match_away_team",
3550
- team: "player_team",
3551
- round: "match_round",
3552
- jumperNumber: "guernsey_number",
3553
- firstName: "player_first_name",
3554
- lastName: "player_last_name",
3555
- playerName: void 0,
3556
- freesFor: "free_kicks_for",
3557
- freesAgainst: "free_kicks_against",
3558
- totalClearances: "clearances",
3559
- inside50s: "inside_fifties",
3560
- rebound50s: "rebounds",
3561
- disposalEfficiency: "disposal_efficiency_percentage",
3562
- marksInside50: "marks_inside_fifty",
3563
- tacklesInside50: "tackles_inside_fifty",
3564
- timeOnGround: "time_on_ground_percentage",
3565
- position: "player_position",
3566
- dreamTeamPoints: "afl_fantasy_score",
3567
- totalPossessions: void 0
3568
- };
3569
- var AFLW_COLUMNS = {
3570
- date: "date",
3571
- homeTeam: "home_team",
3572
- awayTeam: "away_team",
3573
- team: "team",
3574
- round: "fixture_round",
3575
- jumperNumber: "number",
3576
- firstName: void 0,
3577
- lastName: void 0,
3578
- playerName: "player_name",
3579
- freesFor: "frees_for",
3580
- freesAgainst: "frees_against",
3581
- totalClearances: "total_clearances",
3582
- inside50s: "inside50s",
3583
- rebound50s: "rebound50s",
3584
- disposalEfficiency: "disposal_efficiency",
3585
- marksInside50: "marks_inside50",
3586
- tacklesInside50: "tackles_inside50",
3587
- timeOnGround: "time_on_ground",
3588
- position: "position",
3589
- dreamTeamPoints: "fantasy_score",
3590
- totalPossessions: "total_possessions"
3591
- };
3592
- function transformFryziggPlayerStats(frame, options) {
3593
- const colIndex = /* @__PURE__ */ new Map();
3594
- for (let i = 0; i < frame.names.length; i++) {
3595
- const name = frame.names[i];
3596
- if (name !== void 0) {
3597
- colIndex.set(name, i);
4071
+ /**
4072
+ * Fetch games (match results or fixture) from the Squiggle API.
4073
+ *
4074
+ * @param year - Season year.
4075
+ * @param round - Optional round number.
4076
+ * @param complete - Optional completion filter (100 = complete, omit for all).
4077
+ */
4078
+ async fetchGames(year, round, complete) {
4079
+ const params = new URLSearchParams({ q: "games", year: String(year) });
4080
+ if (round != null) params.set("round", String(round));
4081
+ if (complete != null) params.set("complete", String(complete));
4082
+ const result = await this.fetchJson(params);
4083
+ if (!result.success) return result;
4084
+ const parsed = SquiggleGamesResponseSchema.safeParse(result.data);
4085
+ if (!parsed.success) {
4086
+ return err(
4087
+ new ScrapeError(`Invalid Squiggle games response: ${parsed.error.message}`, "squiggle")
4088
+ );
3598
4089
  }
4090
+ return ok(parsed.data);
3599
4091
  }
3600
- for (const group of REQUIRED_COLUMN_GROUPS) {
3601
- if (!group.some((name) => colIndex.has(name))) {
4092
+ /**
4093
+ * Fetch standings (ladder) from the Squiggle API.
4094
+ *
4095
+ * @param year - Season year.
4096
+ * @param round - Optional round number.
4097
+ */
4098
+ async fetchStandings(year, round) {
4099
+ const params = new URLSearchParams({ q: "standings", year: String(year) });
4100
+ if (round != null) params.set("round", String(round));
4101
+ const result = await this.fetchJson(params);
4102
+ if (!result.success) return result;
4103
+ const parsed = SquiggleStandingsResponseSchema.safeParse(result.data);
4104
+ if (!parsed.success) {
3602
4105
  return err(
3603
- new ScrapeError(
3604
- `Fryzigg data frame missing required column: "${group.join('" or "')}"`,
3605
- "fryzigg"
3606
- )
4106
+ new ScrapeError(`Invalid Squiggle standings response: ${parsed.error.message}`, "squiggle")
3607
4107
  );
3608
4108
  }
4109
+ return ok(parsed.data);
3609
4110
  }
3610
- const getCol = (name) => {
3611
- if (name === void 0) return void 0;
3612
- const idx = colIndex.get(name);
3613
- if (idx === void 0) return void 0;
3614
- return frame.columns[idx];
3615
- };
3616
- const isAflw = options.competition === "AFLW";
3617
- const mapping = isAflw ? AFLW_COLUMNS : AFLM_COLUMNS;
3618
- const cols = {
3619
- matchId: getCol("match_id"),
3620
- date: getCol(mapping.date),
3621
- homeTeam: getCol(mapping.homeTeam),
3622
- awayTeam: getCol(mapping.awayTeam),
3623
- team: getCol(mapping.team),
3624
- round: getCol(mapping.round),
3625
- jumperNumber: getCol(mapping.jumperNumber),
3626
- playerId: getCol("player_id"),
3627
- firstName: getCol(mapping.firstName),
3628
- lastName: getCol(mapping.lastName),
3629
- playerName: getCol(mapping.playerName),
3630
- kicks: getCol("kicks"),
3631
- handballs: getCol("handballs"),
3632
- disposals: getCol("disposals"),
3633
- marks: getCol("marks"),
3634
- goals: getCol("goals"),
3635
- behinds: getCol("behinds"),
3636
- tackles: getCol("tackles"),
3637
- hitouts: getCol("hitouts"),
3638
- freesFor: getCol(mapping.freesFor),
3639
- freesAgainst: getCol(mapping.freesAgainst),
3640
- contestedPossessions: getCol("contested_possessions"),
3641
- uncontestedPossessions: getCol("uncontested_possessions"),
3642
- contestedMarks: getCol("contested_marks"),
3643
- intercepts: getCol("intercepts"),
3644
- centreClearances: getCol("centre_clearances"),
3645
- stoppageClearances: getCol("stoppage_clearances"),
3646
- totalClearances: getCol(mapping.totalClearances),
3647
- inside50s: getCol(mapping.inside50s),
3648
- rebound50s: getCol(mapping.rebound50s),
3649
- clangers: getCol("clangers"),
3650
- turnovers: getCol("turnovers"),
3651
- onePercenters: getCol("one_percenters"),
3652
- bounces: getCol("bounces"),
3653
- goalAssists: getCol("goal_assists"),
3654
- disposalEfficiency: getCol(mapping.disposalEfficiency),
3655
- metresGained: getCol("metres_gained"),
3656
- marksInside50: getCol(mapping.marksInside50),
3657
- tacklesInside50: getCol(mapping.tacklesInside50),
3658
- shotsAtGoal: getCol("shots_at_goal"),
3659
- scoreInvolvements: getCol("score_involvements"),
3660
- totalPossessions: getCol(mapping.totalPossessions),
3661
- timeOnGround: getCol(mapping.timeOnGround),
3662
- ratingPoints: getCol("rating_points"),
3663
- position: getCol(mapping.position),
3664
- brownlowVotes: getCol("brownlow_votes"),
3665
- supercoachScore: getCol("supercoach_score"),
3666
- dreamTeamPoints: getCol(mapping.dreamTeamPoints),
3667
- effectiveDisposals: getCol("effective_disposals"),
3668
- effectiveKicks: getCol("effective_kicks"),
3669
- pressureActs: getCol("pressure_acts"),
3670
- defHalfPressureActs: getCol("def_half_pressure_acts"),
3671
- spoils: getCol("spoils"),
3672
- hitoutsToAdvantage: getCol("hitouts_to_advantage"),
3673
- hitoutWinPercentage: getCol("hitout_win_percentage"),
3674
- groundBallGets: getCol("ground_ball_gets"),
3675
- f50GroundBallGets: getCol("f50_ground_ball_gets"),
3676
- interceptMarks: getCol("intercept_marks"),
3677
- marksOnLead: getCol("marks_on_lead"),
3678
- contestOffOneOnOnes: getCol("contest_off_one_on_ones"),
3679
- contestOffWins: getCol("contest_off_wins"),
3680
- contestDefOneOnOnes: getCol("contest_def_one_on_ones"),
3681
- contestDefLosses: getCol("contest_def_losses"),
3682
- ruckContests: getCol("ruck_contests"),
3683
- scoreLaunches: getCol("score_launches")
3684
- };
3685
- const dateCol = cols.date;
3686
- const roundCol = cols.round;
3687
- const nRows = dateCol ? dateCol.length : 0;
3688
- const hasFilters = options.season !== void 0 || options.round !== void 0;
3689
- let rowIndices = null;
3690
- let rowCount = nRows;
3691
- if (hasFilters) {
3692
- const matching = [];
3693
- for (let i = 0; i < nRows; i++) {
3694
- if (options.season !== void 0) {
3695
- const dateStr = dateCol?.[i];
3696
- if (typeof dateStr !== "string") continue;
3697
- const year = Number(dateStr.slice(0, 4));
3698
- if (year !== options.season) continue;
3699
- }
3700
- if (options.round !== void 0 && roundCol) {
3701
- const roundVal = roundCol[i];
3702
- const roundNum = typeof roundVal === "string" ? Number(roundVal) : roundVal;
3703
- if (roundNum !== options.round) continue;
3704
- }
3705
- matching.push(i);
3706
- }
3707
- rowIndices = matching;
3708
- rowCount = matching.length;
4111
+ };
4112
+
4113
+ // src/sources/adapters/squiggle.ts
4114
+ var SQUIGGLE_MATCH_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 2012 }]]);
4115
+ var SQUIGGLE_LADDER_COVERAGE = /* @__PURE__ */ new Map([["AFLM", { minSeason: 2012 }]]);
4116
+ var SquiggleMatchSource = class {
4117
+ constructor(client = new SquiggleClient()) {
4118
+ this.client = client;
3709
4119
  }
3710
- const stats = new Array(rowCount);
3711
- for (let j = 0; j < rowCount; j++) {
3712
- const i = rowIndices ? rowIndices[j] : j;
3713
- stats[j] = mapRow(i, cols, isAflw, options.competition);
4120
+ id = "squiggle";
4121
+ coverage = SQUIGGLE_MATCH_COVERAGE;
4122
+ async fetchMatches(query) {
4123
+ const result = await this.client.fetchGames(query.season, query.round ?? void 0, 100);
4124
+ if (!result.success) return result;
4125
+ return ok(transformSquiggleGamesToFixture(result.data.games, query.season));
3714
4126
  }
3715
- return ok(stats);
3716
- }
3717
- function numAt(column, i) {
3718
- if (!column) return null;
3719
- const v = column[i];
3720
- return typeof v === "number" ? v : null;
3721
- }
3722
- function strAt(column, i) {
3723
- if (!column) return null;
3724
- const v = column[i];
3725
- return typeof v === "string" ? v : null;
3726
- }
3727
- function roundAt(column, i) {
3728
- if (!column) return 0;
3729
- const v = column[i];
3730
- if (typeof v === "number") return v;
3731
- if (typeof v === "string") {
3732
- const n = Number(v);
3733
- return Number.isNaN(n) ? 0 : n;
4127
+ };
4128
+ var SquiggleLadderSource = class {
4129
+ constructor(client = new SquiggleClient()) {
4130
+ this.client = client;
3734
4131
  }
3735
- return 0;
3736
- }
3737
- function mapRow(i, c, isAflw, competition) {
3738
- const dateStr = strAt(c.date, i);
3739
- let firstName;
3740
- let lastName;
3741
- if (isAflw) {
3742
- const playerName = strAt(c.playerName, i) ?? "";
3743
- const commaIdx = playerName.indexOf(", ");
3744
- firstName = commaIdx >= 0 ? playerName.slice(0, commaIdx) : playerName;
3745
- lastName = commaIdx >= 0 ? playerName.slice(commaIdx + 2) : "";
3746
- } else {
3747
- firstName = strAt(c.firstName, i) ?? "";
3748
- lastName = strAt(c.lastName, i) ?? "";
4132
+ id = "squiggle";
4133
+ coverage = SQUIGGLE_LADDER_COVERAGE;
4134
+ async fetchLadder(query) {
4135
+ const competition = query.competition ?? "AFLM";
4136
+ const result = await this.client.fetchStandings(query.season, query.round ?? void 0);
4137
+ if (!result.success) return result;
4138
+ return ok({
4139
+ season: query.season,
4140
+ roundNumber: query.round ?? null,
4141
+ entries: transformSquiggleStandings(result.data.standings),
4142
+ competition
4143
+ });
3749
4144
  }
3750
- const team = strAt(c.team, i) ?? "";
3751
- const homeTeam = strAt(c.homeTeam, i);
3752
- const awayTeam = strAt(c.awayTeam, i);
3753
- return {
3754
- matchId: String(c.matchId?.[i] ?? ""),
3755
- season: dateStr ? Number(dateStr.slice(0, 4)) : 0,
3756
- roundNumber: roundAt(c.round, i),
3757
- team: normaliseTeamName(team),
3758
- competition,
3759
- date: dateStr ? parseDate(dateStr) : null,
3760
- homeTeam: homeTeam ? normaliseTeamName(homeTeam) : null,
3761
- awayTeam: awayTeam ? normaliseTeamName(awayTeam) : null,
3762
- playerId: String(c.playerId?.[i] ?? ""),
3763
- givenName: firstName,
3764
- surname: lastName,
3765
- displayName: `${firstName} ${lastName}`.trim(),
3766
- jumperNumber: numAt(c.jumperNumber, i),
3767
- kicks: numAt(c.kicks, i),
3768
- handballs: numAt(c.handballs, i),
3769
- disposals: numAt(c.disposals, i),
3770
- marks: numAt(c.marks, i),
3771
- goals: numAt(c.goals, i),
3772
- behinds: numAt(c.behinds, i),
3773
- tackles: numAt(c.tackles, i),
3774
- hitouts: numAt(c.hitouts, i),
3775
- freesFor: numAt(c.freesFor, i),
3776
- freesAgainst: numAt(c.freesAgainst, i),
3777
- contestedPossessions: numAt(c.contestedPossessions, i),
3778
- uncontestedPossessions: numAt(c.uncontestedPossessions, i),
3779
- contestedMarks: numAt(c.contestedMarks, i),
3780
- intercepts: numAt(c.intercepts, i),
3781
- centreClearances: numAt(c.centreClearances, i),
3782
- stoppageClearances: numAt(c.stoppageClearances, i),
3783
- totalClearances: numAt(c.totalClearances, i),
3784
- inside50s: numAt(c.inside50s, i),
3785
- rebound50s: numAt(c.rebound50s, i),
3786
- clangers: numAt(c.clangers, i),
3787
- turnovers: numAt(c.turnovers, i),
3788
- onePercenters: numAt(c.onePercenters, i),
3789
- bounces: numAt(c.bounces, i),
3790
- goalAssists: numAt(c.goalAssists, i),
3791
- disposalEfficiency: numAt(c.disposalEfficiency, i),
3792
- metresGained: numAt(c.metresGained, i),
3793
- goalAccuracy: null,
3794
- marksInside50: numAt(c.marksInside50, i),
3795
- tacklesInside50: numAt(c.tacklesInside50, i),
3796
- shotsAtGoal: numAt(c.shotsAtGoal, i),
3797
- scoreInvolvements: numAt(c.scoreInvolvements, i),
3798
- totalPossessions: numAt(c.totalPossessions, i),
3799
- timeOnGroundPercentage: numAt(c.timeOnGround, i),
3800
- ratingPoints: numAt(c.ratingPoints, i),
3801
- position: strAt(c.position, i),
3802
- goalEfficiency: null,
3803
- shotEfficiency: null,
3804
- interchangeCounts: null,
3805
- brownlowVotes: numAt(c.brownlowVotes, i),
3806
- supercoachScore: numAt(c.supercoachScore, i),
3807
- dreamTeamPoints: numAt(c.dreamTeamPoints, i),
3808
- effectiveDisposals: numAt(c.effectiveDisposals, i),
3809
- effectiveKicks: numAt(c.effectiveKicks, i),
3810
- kickEfficiency: null,
3811
- kickToHandballRatio: null,
3812
- pressureActs: numAt(c.pressureActs, i),
3813
- defHalfPressureActs: numAt(c.defHalfPressureActs, i),
3814
- spoils: numAt(c.spoils, i),
3815
- hitoutsToAdvantage: numAt(c.hitoutsToAdvantage, i),
3816
- hitoutWinPercentage: numAt(c.hitoutWinPercentage, i),
3817
- hitoutToAdvantageRate: null,
3818
- groundBallGets: numAt(c.groundBallGets, i),
3819
- f50GroundBallGets: numAt(c.f50GroundBallGets, i),
3820
- interceptMarks: numAt(c.interceptMarks, i),
3821
- marksOnLead: numAt(c.marksOnLead, i),
3822
- contestedPossessionRate: null,
3823
- contestOffOneOnOnes: numAt(c.contestOffOneOnOnes, i),
3824
- contestOffWins: numAt(c.contestOffWins, i),
3825
- contestOffWinsPercentage: null,
3826
- contestDefOneOnOnes: numAt(c.contestDefOneOnOnes, i),
3827
- contestDefLosses: numAt(c.contestDefLosses, i),
3828
- contestDefLossPercentage: null,
3829
- centreBounceAttendances: null,
3830
- kickins: null,
3831
- kickinsPlayon: null,
3832
- ruckContests: numAt(c.ruckContests, i),
3833
- scoreLaunches: numAt(c.scoreLaunches, i),
3834
- source: "fryzigg"
3835
- };
4145
+ };
4146
+
4147
+ // src/sources/adapters/coverage.ts
4148
+ function checkCoverage(coverage, request, suggestion) {
4149
+ const range = coverage.get(request.competition);
4150
+ if (!range) {
4151
+ return err(
4152
+ new UnsupportedCompetitionError(
4153
+ `${request.source} does not provide ${request.operation} data for ${request.competition}`,
4154
+ request.source,
4155
+ request.competition,
4156
+ suggestion
4157
+ )
4158
+ );
4159
+ }
4160
+ if (request.season < range.minSeason) {
4161
+ return err(
4162
+ new OutOfRangeError(
4163
+ `${request.source} only covers ${request.competition} ${request.operation} from ${range.minSeason}; you asked for ${request.season}`,
4164
+ request.source,
4165
+ request.competition,
4166
+ request.season,
4167
+ suggestion
4168
+ )
4169
+ );
4170
+ }
4171
+ if (range.maxSeason != null && request.season > range.maxSeason) {
4172
+ return err(
4173
+ new OutOfRangeError(
4174
+ `${request.source} only covers ${request.competition} ${request.operation} up to ${range.maxSeason}; you asked for ${request.season}`,
4175
+ request.source,
4176
+ request.competition,
4177
+ request.season,
4178
+ suggestion
4179
+ )
4180
+ );
4181
+ }
4182
+ return ok(void 0);
3836
4183
  }
3837
-
3838
- // src/transforms/player-stats.ts
3839
- function toNullable(value) {
3840
- return value ?? null;
4184
+ function unsupportedSourceForOperation(source, operation, registered) {
4185
+ return new UnsupportedSourceError(
4186
+ `${source} does not provide ${operation} data. Supported sources: ${registered.join(", ")}.`,
4187
+ source
4188
+ );
3841
4189
  }
3842
- function transformOne(item, ctx) {
3843
- const inner = item.player.player.player;
3844
- const stats = item.playerStats?.stats;
3845
- const clearances = stats?.clearances;
3846
- return {
3847
- matchId: ctx.matchId,
3848
- season: ctx.season,
3849
- roundNumber: ctx.roundNumber,
3850
- team: normaliseTeamName(
3851
- ctx.teamIdMap?.get(item.teamId) ?? AFL_API_TEAM_IDS.get(item.teamId) ?? item.teamId
3852
- ),
3853
- competition: ctx.competition,
3854
- date: ctx.date ?? null,
3855
- homeTeam: ctx.homeTeam ?? null,
3856
- awayTeam: ctx.awayTeam ?? null,
3857
- playerId: inner.playerId,
3858
- givenName: inner.playerName.givenName,
3859
- surname: inner.playerName.surname,
3860
- displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
3861
- jumperNumber: item.player.jumperNumber ?? null,
3862
- kicks: toNullable(stats?.kicks),
3863
- handballs: toNullable(stats?.handballs),
3864
- disposals: toNullable(stats?.disposals),
3865
- marks: toNullable(stats?.marks),
3866
- goals: toNullable(stats?.goals),
3867
- behinds: toNullable(stats?.behinds),
3868
- tackles: toNullable(stats?.tackles),
3869
- hitouts: toNullable(stats?.hitouts),
3870
- freesFor: toNullable(stats?.freesFor),
3871
- freesAgainst: toNullable(stats?.freesAgainst),
3872
- contestedPossessions: toNullable(stats?.contestedPossessions),
3873
- uncontestedPossessions: toNullable(stats?.uncontestedPossessions),
3874
- contestedMarks: toNullable(stats?.contestedMarks),
3875
- intercepts: toNullable(stats?.intercepts),
3876
- centreClearances: toNullable(clearances?.centreClearances),
3877
- stoppageClearances: toNullable(clearances?.stoppageClearances),
3878
- totalClearances: toNullable(clearances?.totalClearances),
3879
- inside50s: toNullable(stats?.inside50s),
3880
- rebound50s: toNullable(stats?.rebound50s),
3881
- clangers: toNullable(stats?.clangers),
3882
- turnovers: toNullable(stats?.turnovers),
3883
- onePercenters: toNullable(stats?.onePercenters),
3884
- bounces: toNullable(stats?.bounces),
3885
- goalAssists: toNullable(stats?.goalAssists),
3886
- disposalEfficiency: toNullable(stats?.disposalEfficiency),
3887
- metresGained: toNullable(stats?.metresGained),
3888
- goalAccuracy: toNullable(stats?.goalAccuracy),
3889
- marksInside50: toNullable(stats?.marksInside50),
3890
- tacklesInside50: toNullable(stats?.tacklesInside50),
3891
- shotsAtGoal: toNullable(stats?.shotsAtGoal),
3892
- scoreInvolvements: toNullable(stats?.scoreInvolvements),
3893
- totalPossessions: toNullable(stats?.totalPossessions),
3894
- timeOnGroundPercentage: toNullable(item.playerStats?.timeOnGroundPercentage),
3895
- ratingPoints: toNullable(stats?.ratingPoints),
3896
- position: item.player.player.position ?? null,
3897
- goalEfficiency: toNullable(stats?.goalEfficiency),
3898
- shotEfficiency: toNullable(stats?.shotEfficiency),
3899
- interchangeCounts: toNullable(stats?.interchangeCounts),
3900
- brownlowVotes: toNullable(stats?.brownlowVotes),
3901
- supercoachScore: null,
3902
- dreamTeamPoints: toNullable(stats?.dreamTeamPoints),
3903
- effectiveDisposals: toNullable(stats?.extendedStats?.effectiveDisposals),
3904
- effectiveKicks: toNullable(stats?.extendedStats?.effectiveKicks),
3905
- kickEfficiency: toNullable(stats?.extendedStats?.kickEfficiency),
3906
- kickToHandballRatio: toNullable(stats?.extendedStats?.kickToHandballRatio),
3907
- pressureActs: toNullable(stats?.extendedStats?.pressureActs),
3908
- defHalfPressureActs: toNullable(stats?.extendedStats?.defHalfPressureActs),
3909
- spoils: toNullable(stats?.extendedStats?.spoils),
3910
- hitoutsToAdvantage: toNullable(stats?.extendedStats?.hitoutsToAdvantage),
3911
- hitoutWinPercentage: toNullable(stats?.extendedStats?.hitoutWinPercentage),
3912
- hitoutToAdvantageRate: toNullable(stats?.extendedStats?.hitoutToAdvantageRate),
3913
- groundBallGets: toNullable(stats?.extendedStats?.groundBallGets),
3914
- f50GroundBallGets: toNullable(stats?.extendedStats?.f50GroundBallGets),
3915
- interceptMarks: toNullable(stats?.extendedStats?.interceptMarks),
3916
- marksOnLead: toNullable(stats?.extendedStats?.marksOnLead),
3917
- contestedPossessionRate: toNullable(stats?.extendedStats?.contestedPossessionRate),
3918
- contestOffOneOnOnes: toNullable(stats?.extendedStats?.contestOffOneOnOnes),
3919
- contestOffWins: toNullable(stats?.extendedStats?.contestOffWins),
3920
- contestOffWinsPercentage: toNullable(stats?.extendedStats?.contestOffWinsPercentage),
3921
- contestDefOneOnOnes: toNullable(stats?.extendedStats?.contestDefOneOnOnes),
3922
- contestDefLosses: toNullable(stats?.extendedStats?.contestDefLosses),
3923
- contestDefLossPercentage: toNullable(stats?.extendedStats?.contestDefLossPercentage),
3924
- centreBounceAttendances: toNullable(stats?.extendedStats?.centreBounceAttendances),
3925
- kickins: toNullable(stats?.extendedStats?.kickins),
3926
- kickinsPlayon: toNullable(stats?.extendedStats?.kickinsPlayon),
3927
- ruckContests: toNullable(stats?.extendedStats?.ruckContests),
3928
- scoreLaunches: toNullable(stats?.extendedStats?.scoreLaunches),
3929
- source: ctx.source
3930
- };
4190
+ function findAlternativeSource(adapters, request) {
4191
+ for (const adapter of adapters) {
4192
+ if (adapter.id === request.source) continue;
4193
+ const range = adapter.coverage.get(request.competition);
4194
+ if (!range) continue;
4195
+ if (request.season < range.minSeason) continue;
4196
+ if (range.maxSeason != null && request.season > range.maxSeason) continue;
4197
+ return adapter.id;
4198
+ }
4199
+ return void 0;
3931
4200
  }
3932
- function transformPlayerStats(data, ctx) {
3933
- const home = (data.homeTeamPlayerStats ?? []).map((item) => transformOne(item, ctx));
3934
- const away = (data.awayTeamPlayerStats ?? []).map((item) => transformOne(item, ctx));
3935
- return [...home, ...away];
4201
+
4202
+ // src/sources/adapters/dispatch.ts
4203
+ function dispatch(registry, operation, query) {
4204
+ const adapter = registry.get(query.source);
4205
+ if (!adapter) {
4206
+ return err(unsupportedSourceForOperation(query.source, operation, registry.list()));
4207
+ }
4208
+ const competition = query.competition ?? "AFLM";
4209
+ const alternative = findAlternativeSource(registry.all(), {
4210
+ source: query.source,
4211
+ competition,
4212
+ season: query.season
4213
+ });
4214
+ const suggestion = alternative ? `--source ${alternative}` : void 0;
4215
+ const coverage = checkCoverage(
4216
+ adapter.coverage,
4217
+ { source: query.source, operation, competition, season: query.season },
4218
+ suggestion
4219
+ );
4220
+ if (!coverage.success) return coverage;
4221
+ return ok(adapter);
3936
4222
  }
3937
4223
 
4224
+ // src/sources/adapters/index.ts
4225
+ matchRegistry.register(new AflApiMatchSource());
4226
+ matchRegistry.register(new FootyWireMatchSource());
4227
+ matchRegistry.register(new AflTablesMatchSource());
4228
+ matchRegistry.register(new SquiggleMatchSource());
4229
+ playerStatsRegistry.register(new AflApiPlayerStatsSource());
4230
+ playerStatsRegistry.register(new FootyWirePlayerStatsSource());
4231
+ playerStatsRegistry.register(new AflTablesPlayerStatsSource());
4232
+ playerStatsRegistry.register(new FryziggPlayerStatsSource());
4233
+ teamStatsRegistry.register(new FootyWireTeamStatsSource());
4234
+ teamStatsRegistry.register(new AflTablesTeamStatsSource());
4235
+ squadRegistry.register(new AflApiSquadSource());
4236
+ squadRegistry.register(new FootyWireSquadSource());
4237
+ squadRegistry.register(new AflTablesSquadSource());
4238
+ lineupRegistry.register(new AflApiLineupSource());
4239
+ ladderRegistry.register(new AflApiLadderSource());
4240
+ ladderRegistry.register(new AflTablesLadderSource());
4241
+ ladderRegistry.register(new SquiggleLadderSource());
4242
+
3938
4243
  // src/api/player-stats.ts
3939
4244
  async function fetchPlayerStats(query) {
3940
- const competition = query.competition ?? "AFLM";
3941
- switch (query.source) {
3942
- case "afl-api": {
3943
- const client = new AflApiClient();
3944
- if (query.matchId) {
3945
- const [rosterResult, statsResult] = await Promise.all([
3946
- client.fetchMatchRoster(query.matchId),
3947
- client.fetchPlayerStats(query.matchId)
3948
- ]);
3949
- if (!statsResult.success) return statsResult;
3950
- const teamIdMap2 = new Map(AFL_API_TEAM_IDS);
3951
- if (rosterResult.success) {
3952
- const match = rosterResult.data.match;
3953
- teamIdMap2.set(match.homeTeamId, normaliseTeamName(match.homeTeam.name));
3954
- teamIdMap2.set(match.awayTeamId, normaliseTeamName(match.awayTeam.name));
3955
- }
3956
- return ok(
3957
- transformPlayerStats(statsResult.data, {
3958
- matchId: query.matchId,
3959
- season: query.season,
3960
- roundNumber: query.round ?? 0,
3961
- competition,
3962
- source: "afl-api",
3963
- teamIdMap: teamIdMap2
3964
- })
3965
- );
3966
- }
3967
- const seasonResult = await client.resolveCompSeason(competition, query.season);
3968
- if (!seasonResult.success) return seasonResult;
3969
- const matchItemsResult = query.round != null ? await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round) : await client.fetchSeasonMatchItems(seasonResult.data);
3970
- if (!matchItemsResult.success) return matchItemsResult;
3971
- const teamIdMap = /* @__PURE__ */ new Map();
3972
- for (const item of matchItemsResult.data) {
3973
- teamIdMap.set(item.match.homeTeamId, item.match.homeTeam.name);
3974
- teamIdMap.set(item.match.awayTeamId, item.match.awayTeam.name);
3975
- }
3976
- const statsResults = await batchedMap(
3977
- matchItemsResult.data,
3978
- (item) => client.fetchPlayerStats(item.match.matchId)
4245
+ const adapterR = dispatch(playerStatsRegistry, "player stats", query);
4246
+ return Result.flatMapAsync(adapterR, (a) => a.fetchPlayerStats(query));
4247
+ }
4248
+
4249
+ // src/api/awards.ts
4250
+ var FOOTYWIRE_BASE2 = "https://www.footywire.com/afl/footy";
4251
+ async function fetchAwards(query) {
4252
+ switch (query.award) {
4253
+ case "brownlow":
4254
+ return fetchFootyWireAward(
4255
+ `${FOOTYWIRE_BASE2}/brownlow_medal?year=${query.season}`,
4256
+ (html) => parseBrownlowVotes(html, query.season),
4257
+ "Brownlow",
4258
+ query.season
3979
4259
  );
3980
- const allStats = [];
3981
- for (let i = 0; i < statsResults.length; i++) {
3982
- const statsResult = statsResults[i];
3983
- if (!statsResult?.success)
3984
- return statsResult ?? err(new AflApiError("Missing stats result"));
3985
- const item = matchItemsResult.data[i];
3986
- if (!item) continue;
3987
- allStats.push(
3988
- ...transformPlayerStats(statsResult.data, {
3989
- matchId: item.match.matchId,
3990
- season: query.season,
3991
- roundNumber: item.round?.roundNumber ?? query.round ?? 0,
3992
- competition,
3993
- source: "afl-api",
3994
- teamIdMap,
3995
- date: parseDate(item.match.utcStartTime) ?? new Date(item.match.utcStartTime),
3996
- homeTeam: normaliseTeamName(item.match.homeTeam.name),
3997
- awayTeam: normaliseTeamName(item.match.awayTeam.name)
3998
- })
3999
- );
4000
- }
4001
- return ok(allStats);
4002
- }
4003
- case "footywire": {
4004
- if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
4005
- const fwClient = new FootyWireClient();
4006
- const idsResult = await fwClient.fetchSeasonMatchIds(query.season);
4007
- if (!idsResult.success) return idsResult;
4008
- const entries = query.round != null ? idsResult.data.filter((e) => e.roundNumber === query.round) : idsResult.data;
4009
- if (entries.length === 0) {
4010
- return ok([]);
4011
- }
4012
- const allStats = [];
4013
- const batchSize = 5;
4014
- for (let i = 0; i < entries.length; i += batchSize) {
4015
- const batch = entries.slice(i, i + batchSize);
4016
- const results = await Promise.all(
4017
- batch.map((e) => fwClient.fetchMatchPlayerStats(e.matchId, query.season, e.roundNumber))
4018
- );
4019
- for (const result of results) {
4020
- if (result.success) {
4021
- allStats.push(...result.data);
4022
- }
4023
- }
4024
- if (i + batchSize < entries.length) {
4025
- await new Promise((resolve) => setTimeout(resolve, 500));
4026
- }
4027
- }
4028
- return ok(allStats);
4029
- }
4030
- case "afl-tables": {
4031
- if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
4032
- const atClient = new AflTablesClient();
4033
- const atResult = await atClient.fetchSeasonPlayerStats(query.season);
4034
- if (!atResult.success) return atResult;
4035
- if (query.round != null) {
4036
- return ok(atResult.data.filter((s) => s.roundNumber === query.round));
4037
- }
4038
- return atResult;
4039
- }
4040
- case "fryzigg": {
4041
- const fzClient = new FryziggClient();
4042
- const fzResult = await fzClient.fetchPlayerStats(competition);
4043
- if (!fzResult.success) return fzResult;
4044
- return transformFryziggPlayerStats(fzResult.data, {
4045
- competition,
4046
- season: query.season,
4047
- round: query.round
4260
+ case "all-australian":
4261
+ return fetchFootyWireAward(
4262
+ `${FOOTYWIRE_BASE2}/all_australian_selection?year=${query.season}`,
4263
+ (html) => parseAllAustralian(html, query.season),
4264
+ "All-Australian",
4265
+ query.season
4266
+ );
4267
+ case "rising-star":
4268
+ return fetchFootyWireAward(
4269
+ `${FOOTYWIRE_BASE2}/rising_star_nominations?year=${query.season}`,
4270
+ (html) => parseRisingStarNominations(html, query.season),
4271
+ "Rising Star",
4272
+ query.season
4273
+ );
4274
+ case "coaches":
4275
+ return fetchCoachesVotes(query);
4276
+ case "coleman":
4277
+ return fetchColemanLeaderboard(query);
4278
+ default:
4279
+ return err(
4280
+ new ScrapeError(`Unknown award type: ${query.award}`, "footywire")
4281
+ );
4282
+ }
4283
+ }
4284
+ async function fetchFootyWireAward(url, parse, label, season) {
4285
+ const client = new FootyWireClient();
4286
+ const htmlResult = await client.fetchPage(url);
4287
+ if (!htmlResult.success) return htmlResult;
4288
+ const data = parse(htmlResult.data);
4289
+ if (data.length === 0) {
4290
+ return err(new ScrapeError(`No ${label} data found for season ${season}`, "footywire"));
4291
+ }
4292
+ return ok(data);
4293
+ }
4294
+ async function fetchCoachesVotes(query) {
4295
+ const competition = query.competition ?? "AFLM";
4296
+ if (query.season < 2006) {
4297
+ return err(new ScrapeError("No coaches votes data available before 2006", "afl-coaches"));
4298
+ }
4299
+ if (competition === "AFLW" && query.season < 2018) {
4300
+ return err(new ScrapeError("No AFLW coaches votes data available before 2018", "afl-coaches"));
4301
+ }
4302
+ if (competition === "VFL" || competition === "VFLW") {
4303
+ return err(
4304
+ new ScrapeError(`No coaches votes data available for ${competition}`, "afl-coaches")
4305
+ );
4306
+ }
4307
+ const client = new AflCoachesClient();
4308
+ const result = query.round != null ? await client.scrapeRoundVotes(
4309
+ query.season,
4310
+ query.round,
4311
+ competition,
4312
+ query.round >= 24 && query.season >= 2018
4313
+ ) : await client.fetchSeasonVotes(query.season, competition);
4314
+ if (!result.success) return result;
4315
+ let votes = result.data;
4316
+ if (query.team != null) {
4317
+ const target = normaliseTeamName(query.team);
4318
+ votes = votes.filter(
4319
+ (v) => normaliseTeamName(v.homeTeam) === target || normaliseTeamName(v.awayTeam) === target
4320
+ );
4321
+ }
4322
+ return ok(votes);
4323
+ }
4324
+ async function fetchColemanLeaderboard(query) {
4325
+ const competition = query.competition ?? "AFLM";
4326
+ if (competition === "VFL" || competition === "VFLW") {
4327
+ return err(
4328
+ new ScrapeError(
4329
+ `Coleman Medal is not awarded in ${competition}; use Coleman-equivalent stats query`,
4330
+ "afl-api"
4331
+ )
4332
+ );
4333
+ }
4334
+ const statsR = await fetchPlayerStats({
4335
+ source: "afl-api",
4336
+ season: query.season,
4337
+ competition
4338
+ });
4339
+ return Result.map(statsR, (stats) => rankColemanFromStats(stats, query.season, query.limit));
4340
+ }
4341
+ function rankColemanFromStats(stats, season, limit) {
4342
+ const accumulator = /* @__PURE__ */ new Map();
4343
+ for (const s of stats) {
4344
+ if (s.goals == null) continue;
4345
+ const key = s.playerId;
4346
+ const existing = accumulator.get(key);
4347
+ if (existing) {
4348
+ existing.goals += s.goals;
4349
+ existing.gamesPlayed += 1;
4350
+ } else {
4351
+ accumulator.set(key, {
4352
+ player: s.displayName,
4353
+ team: s.team,
4354
+ goals: s.goals,
4355
+ gamesPlayed: 1
4048
4356
  });
4049
4357
  }
4050
- default:
4051
- return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
4052
4358
  }
4359
+ const ranked = [...accumulator.values()].filter((entry) => entry.goals > 0).sort((a, b) => b.goals - a.goals);
4360
+ let lastGoals = -1;
4361
+ let lastPosition = 0;
4362
+ const leaderboard = ranked.map((entry, index) => {
4363
+ const position = entry.goals === lastGoals ? lastPosition : index + 1;
4364
+ lastGoals = entry.goals;
4365
+ lastPosition = position;
4366
+ return {
4367
+ type: "coleman",
4368
+ season,
4369
+ position,
4370
+ player: entry.player,
4371
+ team: entry.team,
4372
+ goals: entry.goals,
4373
+ gamesPlayed: entry.gamesPlayed
4374
+ };
4375
+ });
4376
+ return limit != null ? leaderboard.slice(0, limit) : leaderboard;
4053
4377
  }
4054
4378
 
4055
- // src/api/team-stats.ts
4056
- async function fetchTeamStats(query) {
4057
- const summaryType = query.summaryType ?? "totals";
4058
- switch (query.source) {
4059
- case "footywire": {
4060
- const client = new FootyWireClient();
4061
- return client.fetchTeamStats(query.season, summaryType);
4062
- }
4063
- case "afl-tables": {
4064
- const client = new AflTablesClient();
4065
- const statsResult = await client.fetchTeamStats(query.season);
4066
- if (!statsResult.success) return statsResult;
4067
- const needsGp = statsResult.data.some((e) => e.gamesPlayed === 0);
4068
- const gpMap = /* @__PURE__ */ new Map();
4069
- if (needsGp) {
4070
- const resultsResult = await client.fetchSeasonResults(query.season);
4071
- if (resultsResult.success) {
4072
- for (const m of resultsResult.data) {
4073
- const home = normaliseTeamName(m.homeTeam);
4074
- const away = normaliseTeamName(m.awayTeam);
4075
- gpMap.set(home, (gpMap.get(home) ?? 0) + 1);
4076
- gpMap.set(away, (gpMap.get(away) ?? 0) + 1);
4077
- }
4078
- }
4079
- }
4080
- const enriched = statsResult.data.map((entry) => ({
4081
- ...entry,
4082
- gamesPlayed: gpMap.get(normaliseTeamName(entry.team)) ?? entry.gamesPlayed
4083
- }));
4084
- if (summaryType === "averages") {
4085
- return ok(
4086
- enriched.map((entry) => ({
4087
- ...entry,
4088
- stats: Object.fromEntries(
4089
- Object.entries(entry.stats).map(([k, v]) => [
4090
- k,
4091
- entry.gamesPlayed > 0 ? +(v / entry.gamesPlayed).toFixed(1) : 0
4092
- ])
4093
- )
4094
- }))
4095
- );
4096
- }
4097
- return ok(enriched);
4098
- }
4099
- case "afl-api":
4100
- case "squiggle":
4101
- return err(
4102
- new UnsupportedSourceError(
4103
- `Team stats are not available from ${query.source}. Use "footywire" or "afl-tables".`,
4104
- query.source
4105
- )
4106
- );
4107
- default:
4108
- return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
4379
+ // src/api/ladder.ts
4380
+ async function fetchLadder(query) {
4381
+ const adapterR = dispatch(ladderRegistry, "ladder", query);
4382
+ return Result.flatMapAsync(adapterR, (a) => a.fetchLadder(query));
4383
+ }
4384
+
4385
+ // src/api/lineup.ts
4386
+ async function fetchLineup(query) {
4387
+ const adapterR = dispatch(lineupRegistry, "lineup", query);
4388
+ return Result.flatMapAsync(adapterR, (a) => a.fetchLineup(query));
4389
+ }
4390
+
4391
+ // src/transforms/match.ts
4392
+ function filterMatches(matches, query) {
4393
+ let filtered = matches;
4394
+ if (query.matchId !== void 0) {
4395
+ filtered = filtered.filter((m) => m.matchId === query.matchId);
4396
+ }
4397
+ if (query.team !== void 0) {
4398
+ const target = normaliseTeamName(query.team);
4399
+ filtered = filtered.filter((m) => m.homeTeam === target || m.awayTeam === target);
4109
4400
  }
4401
+ if (query.status !== void 0) {
4402
+ filtered = filtered.filter((m) => m.status === query.status);
4403
+ }
4404
+ return [...filtered];
4110
4405
  }
4111
4406
 
4112
- // src/api/teams.ts
4113
- function teamTypeForComp(comp) {
4114
- return comp === "AFLW" ? "WOMEN" : "MEN";
4407
+ // src/api/match.ts
4408
+ async function fetchMatches(query) {
4409
+ const adapterR = dispatch(matchRegistry, "match", query);
4410
+ const fetchedR = await Result.flatMapAsync(adapterR, (a) => a.fetchMatches(query));
4411
+ return Result.map(fetchedR, (matches) => filterMatches(matches, query));
4412
+ }
4413
+
4414
+ // src/transforms/player-details.ts
4415
+ function squadToPlayerDetails(squad, source) {
4416
+ return squad.players.map((p) => ({
4417
+ playerId: p.playerId,
4418
+ givenName: p.givenName,
4419
+ surname: p.surname,
4420
+ displayName: p.displayName,
4421
+ team: squad.teamName,
4422
+ jumperNumber: p.jumperNumber,
4423
+ position: p.position,
4424
+ dateOfBirth: p.dateOfBirth ? p.dateOfBirth.toISOString().slice(0, 10) : null,
4425
+ heightCm: p.heightCm,
4426
+ weightKg: p.weightKg,
4427
+ gamesPlayed: p.gamesPlayed ?? null,
4428
+ goals: p.goals ?? null,
4429
+ draftYear: p.draftYear,
4430
+ draftPosition: p.draftPosition,
4431
+ draftType: p.draftType,
4432
+ debutYear: p.debutYear,
4433
+ recruitedFrom: p.recruitedFrom,
4434
+ source,
4435
+ competition: squad.competition
4436
+ }));
4115
4437
  }
4438
+
4439
+ // src/api/teams.ts
4116
4440
  function toTeams(data, competition) {
4117
4441
  return data.map((t) => ({
4118
4442
  teamId: String(t.id),
@@ -4123,55 +4447,65 @@ function toTeams(data, competition) {
4123
4447
  }
4124
4448
  async function fetchTeams(query) {
4125
4449
  const client = new AflApiClient();
4126
- if (!query?.competition && !query?.teamType) {
4450
+ if (!query?.competition) {
4127
4451
  const [menResult, womenResult] = await Promise.all([
4128
- client.fetchTeams("MEN"),
4129
- client.fetchTeams("WOMEN")
4452
+ client.fetchTeams("AFLM"),
4453
+ client.fetchTeams("AFLW")
4130
4454
  ]);
4131
4455
  if (!menResult.success) return menResult;
4132
4456
  if (!womenResult.success) return womenResult;
4133
4457
  return ok([...toTeams(menResult.data, "AFLM"), ...toTeams(womenResult.data, "AFLW")]);
4134
4458
  }
4135
- const competition = query?.competition ?? "AFLM";
4136
- const teamType = query?.teamType ?? teamTypeForComp(competition);
4137
- const result = await client.fetchTeams(teamType);
4459
+ const result = await client.fetchTeams(query.competition);
4138
4460
  if (!result.success) return result;
4139
- return ok(toTeams(result.data, competition));
4461
+ return ok(toTeams(result.data, query.competition));
4140
4462
  }
4141
4463
  async function fetchSquad(query) {
4142
- const client = new AflApiClient();
4143
- const competition = query.competition ?? "AFLM";
4144
- const seasonResult = await client.resolveCompSeason(competition, query.season);
4145
- if (!seasonResult.success) return seasonResult;
4146
- const teamId = Number.parseInt(query.teamId, 10);
4147
- if (Number.isNaN(teamId)) {
4148
- return err(new ValidationError(`Invalid team ID: ${query.teamId}`));
4149
- }
4150
- const squadResult = await client.fetchSquad(teamId, seasonResult.data);
4151
- if (!squadResult.success) return squadResult;
4152
- const players = squadResult.data.squad.players.map((p) => ({
4153
- playerId: p.player.providerId ?? String(p.player.id),
4154
- givenName: p.player.firstName,
4155
- surname: p.player.surname,
4156
- displayName: `${p.player.firstName} ${p.player.surname}`,
4157
- jumperNumber: p.jumperNumber ?? null,
4158
- position: p.position ?? null,
4159
- dateOfBirth: p.player.dateOfBirth ? parseDate(p.player.dateOfBirth) : null,
4160
- heightCm: p.player.heightInCm || null,
4161
- weightKg: p.player.weightInKg || null,
4162
- draftYear: p.player.draftYear ? Number.parseInt(p.player.draftYear, 10) || null : null,
4163
- draftPosition: p.player.draftPosition ? Number.parseInt(p.player.draftPosition, 10) || null : null,
4164
- draftType: p.player.draftType ?? null,
4165
- debutYear: p.player.debutYear ? Number.parseInt(p.player.debutYear, 10) || null : null,
4166
- recruitedFrom: p.player.recruitedFrom ?? null
4167
- }));
4168
- return ok({
4169
- teamId: query.teamId,
4170
- teamName: normaliseTeamName(squadResult.data.squad.team?.name ?? query.teamId),
4171
- season: query.season,
4172
- players,
4173
- competition
4464
+ const source = query.source ?? squadRegistry.defaultSource;
4465
+ const adapterR = dispatch(squadRegistry, "squad", {
4466
+ source,
4467
+ competition: query.competition,
4468
+ season: query.season
4174
4469
  });
4470
+ return Result.flatMapAsync(adapterR, (a) => a.fetchSquad({ ...query, source }));
4471
+ }
4472
+
4473
+ // src/api/player-details.ts
4474
+ async function fetchPlayerDetails(query) {
4475
+ const competition = query.competition ?? "AFLM";
4476
+ if (query.team) {
4477
+ const squadR = await fetchSquad({
4478
+ team: query.team,
4479
+ season: query.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
4480
+ source: query.source,
4481
+ competition
4482
+ });
4483
+ if (!squadR.success) return squadR;
4484
+ return ok(squadToPlayerDetails(squadR.data, query.source));
4485
+ }
4486
+ const teamNames = [...AFL_SENIOR_TEAMS];
4487
+ const results = await batchedMap(
4488
+ teamNames,
4489
+ (team) => fetchSquad({
4490
+ team,
4491
+ season: query.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
4492
+ source: query.source,
4493
+ competition
4494
+ })
4495
+ );
4496
+ const allPlayers = [];
4497
+ for (const result of results) {
4498
+ if (result.success) {
4499
+ allPlayers.push(...squadToPlayerDetails(result.data, query.source));
4500
+ }
4501
+ }
4502
+ return ok(allPlayers);
4503
+ }
4504
+
4505
+ // src/api/team-stats.ts
4506
+ async function fetchTeamStats(query) {
4507
+ const adapterR = dispatch(teamStatsRegistry, "team stats", { ...query, competition: "AFLM" });
4508
+ return Result.flatMapAsync(adapterR, (a) => a.fetchTeamStats(query));
4175
4509
  }
4176
4510
  export {
4177
4511
  AflApiClient,
@@ -4221,11 +4555,9 @@ export {
4221
4555
  computeLadder,
4222
4556
  err,
4223
4557
  fetchAwards,
4224
- fetchCoachesVotes,
4225
- fetchFixture,
4226
4558
  fetchLadder,
4227
4559
  fetchLineup,
4228
- fetchMatchResults,
4560
+ fetchMatches,
4229
4561
  fetchPlayerDetails,
4230
4562
  fetchPlayerStats,
4231
4563
  fetchSquad,