fitzroy 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/dist/cli.js +2630 -673
  2. package/dist/index.d.ts +1372 -914
  3. package/dist/index.js +2874 -1056
  4. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -36,10 +36,128 @@ function err(error) {
36
36
  return { success: false, error };
37
37
  }
38
38
 
39
+ // src/sources/footywire.ts
40
+ import * as cheerio2 from "cheerio";
41
+
42
+ // src/lib/date-utils.ts
43
+ function parseAflApiDate(iso) {
44
+ const date = new Date(iso);
45
+ if (Number.isNaN(date.getTime())) {
46
+ return null;
47
+ }
48
+ return date;
49
+ }
50
+ function parseFootyWireDate(dateStr) {
51
+ const trimmed = dateStr.trim();
52
+ if (trimmed === "") {
53
+ return null;
54
+ }
55
+ const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
56
+ const normalised = withoutDow.replace(/-/g, " ");
57
+ const match = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
58
+ if (!match) {
59
+ return null;
60
+ }
61
+ const [, dayStr, monthStr, yearStr] = match;
62
+ if (!dayStr || !monthStr || !yearStr) {
63
+ return null;
64
+ }
65
+ const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
66
+ if (monthIndex === void 0) {
67
+ return null;
68
+ }
69
+ const year = Number.parseInt(yearStr, 10);
70
+ const day = Number.parseInt(dayStr, 10);
71
+ const date = new Date(Date.UTC(year, monthIndex, day));
72
+ if (Number.isNaN(date.getTime())) {
73
+ return null;
74
+ }
75
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
76
+ return null;
77
+ }
78
+ return date;
79
+ }
80
+ function parseAflTablesDate(dateStr) {
81
+ const trimmed = dateStr.trim();
82
+ if (trimmed === "") {
83
+ return null;
84
+ }
85
+ const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
86
+ const normalised = withoutDow.replace(/[-/]/g, " ");
87
+ const dmy = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
88
+ if (dmy) {
89
+ const [, dayStr, monthStr, yearStr] = dmy;
90
+ if (dayStr && monthStr && yearStr) {
91
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
92
+ }
93
+ }
94
+ const mdy = /^([A-Za-z]+)\s+(\d{1,2})\s+(\d{4})$/.exec(normalised);
95
+ if (mdy) {
96
+ const [, monthStr, dayStr, yearStr] = mdy;
97
+ if (dayStr && monthStr && yearStr) {
98
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+ function toAestString(date) {
104
+ const formatter = new Intl.DateTimeFormat("en-AU", {
105
+ timeZone: "Australia/Melbourne",
106
+ weekday: "short",
107
+ day: "numeric",
108
+ month: "short",
109
+ year: "numeric",
110
+ hour: "numeric",
111
+ minute: "2-digit",
112
+ hour12: true,
113
+ timeZoneName: "short"
114
+ });
115
+ return formatter.format(date);
116
+ }
117
+ var MONTH_ABBREV_TO_INDEX = /* @__PURE__ */ new Map([
118
+ ["jan", 0],
119
+ ["feb", 1],
120
+ ["mar", 2],
121
+ ["apr", 3],
122
+ ["may", 4],
123
+ ["jun", 5],
124
+ ["jul", 6],
125
+ ["aug", 7],
126
+ ["sep", 8],
127
+ ["oct", 9],
128
+ ["nov", 10],
129
+ ["dec", 11],
130
+ ["january", 0],
131
+ ["february", 1],
132
+ ["march", 2],
133
+ ["april", 3],
134
+ ["june", 5],
135
+ ["july", 6],
136
+ ["august", 7],
137
+ ["september", 8],
138
+ ["october", 9],
139
+ ["november", 10],
140
+ ["december", 11]
141
+ ]);
142
+ function buildUtcDate(year, monthStr, day) {
143
+ const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
144
+ if (monthIndex === void 0) {
145
+ return null;
146
+ }
147
+ const date = new Date(Date.UTC(year, monthIndex, day));
148
+ if (Number.isNaN(date.getTime())) {
149
+ return null;
150
+ }
151
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
152
+ return null;
153
+ }
154
+ return date;
155
+ }
156
+
39
157
  // src/lib/team-mapping.ts
40
158
  var TEAM_ALIASES = [
41
159
  ["Adelaide Crows", "Adelaide", "Crows", "ADEL", "AD"],
42
- ["Brisbane Lions", "Brisbane", "Brisbane Bears", "Bears", "Fitzroy Lions", "BL", "BRIS"],
160
+ ["Brisbane Lions", "Brisbane", "Brisbane Bears", "Bears", "Lions", "Fitzroy Lions", "BL", "BRIS"],
43
161
  ["Carlton", "Carlton Blues", "Blues", "CARL", "CA"],
44
162
  ["Collingwood", "Collingwood Magpies", "Magpies", "COLL", "CW"],
45
163
  ["Essendon", "Essendon Bombers", "Bombers", "ESS", "ES"],
@@ -76,6 +194,26 @@ var TEAM_ALIASES = [
76
194
  ["Fitzroy", "Fitzroy Reds", "Fitzroy Gorillas", "Fitzroy Maroons", "FI"],
77
195
  ["University", "University Blacks"]
78
196
  ];
197
+ var AFL_SENIOR_TEAMS = /* @__PURE__ */ new Set([
198
+ "Adelaide Crows",
199
+ "Brisbane Lions",
200
+ "Carlton",
201
+ "Collingwood",
202
+ "Essendon",
203
+ "Fremantle",
204
+ "Geelong Cats",
205
+ "Gold Coast Suns",
206
+ "GWS Giants",
207
+ "Hawthorn",
208
+ "Melbourne",
209
+ "North Melbourne",
210
+ "Port Adelaide",
211
+ "Richmond",
212
+ "St Kilda",
213
+ "Sydney Swans",
214
+ "West Coast Eagles",
215
+ "Western Bulldogs"
216
+ ]);
79
217
  var ALIAS_MAP = (() => {
80
218
  const map = /* @__PURE__ */ new Map();
81
219
  for (const [canonical, ...aliases] of TEAM_ALIASES) {
@@ -91,1007 +229,2188 @@ function normaliseTeamName(raw) {
91
229
  return ALIAS_MAP.get(trimmed.toLowerCase()) ?? trimmed;
92
230
  }
93
231
 
94
- // src/lib/validation.ts
95
- import { z } from "zod/v4";
96
- var AflApiTokenSchema = z.object({
97
- token: z.string(),
98
- disclaimer: z.string().optional()
99
- }).passthrough();
100
- var CompetitionSchema = z.object({
101
- id: z.number(),
102
- name: z.string(),
103
- code: z.string().optional()
104
- }).passthrough();
105
- var CompetitionListSchema = z.object({
106
- competitions: z.array(CompetitionSchema)
107
- }).passthrough();
108
- var CompseasonSchema = z.object({
109
- id: z.number(),
110
- name: z.string(),
111
- shortName: z.string().optional(),
112
- currentRoundNumber: z.number().optional()
113
- }).passthrough();
114
- var CompseasonListSchema = z.object({
115
- compSeasons: z.array(CompseasonSchema)
116
- }).passthrough();
117
- var RoundSchema = z.object({
118
- id: z.number(),
119
- /** Provider ID used by /cfs/ endpoints (e.g. "CD_R202501401"). */
120
- providerId: z.string().optional(),
121
- name: z.string(),
122
- abbreviation: z.string().optional(),
123
- roundNumber: z.number(),
124
- utcStartTime: z.string().optional(),
125
- utcEndTime: z.string().optional()
126
- }).passthrough();
127
- var RoundListSchema = z.object({
128
- rounds: z.array(RoundSchema)
129
- }).passthrough();
130
- var ScoreSchema = z.object({
131
- totalScore: z.number(),
132
- goals: z.number(),
133
- behinds: z.number(),
134
- superGoals: z.number().nullable().optional()
135
- }).passthrough();
136
- var PeriodScoreSchema = z.object({
137
- periodNumber: z.number(),
138
- score: ScoreSchema
139
- }).passthrough();
140
- var TeamScoreSchema = z.object({
141
- matchScore: ScoreSchema,
142
- periodScore: z.array(PeriodScoreSchema).optional(),
143
- rushedBehinds: z.number().optional(),
144
- minutesInFront: z.number().optional()
145
- }).passthrough();
146
- var CfsMatchTeamSchema = z.object({
147
- name: z.string(),
148
- teamId: z.string(),
149
- abbr: z.string().optional(),
150
- nickname: z.string().optional()
151
- }).passthrough();
152
- var CfsMatchSchema = z.object({
153
- matchId: z.string(),
154
- name: z.string().optional(),
155
- status: z.string(),
156
- utcStartTime: z.string(),
157
- homeTeamId: z.string(),
158
- awayTeamId: z.string(),
159
- homeTeam: CfsMatchTeamSchema,
160
- awayTeam: CfsMatchTeamSchema,
161
- round: z.string().optional(),
162
- abbr: z.string().optional()
163
- }).passthrough();
164
- var CfsScoreSchema = z.object({
165
- status: z.string(),
166
- matchId: z.string(),
167
- homeTeamScore: TeamScoreSchema,
168
- awayTeamScore: TeamScoreSchema
169
- }).passthrough();
170
- var CfsVenueSchema = z.object({
171
- name: z.string(),
172
- venueId: z.string().optional(),
173
- state: z.string().optional(),
174
- timeZone: z.string().optional()
175
- }).passthrough();
176
- var MatchItemSchema = z.object({
177
- match: CfsMatchSchema,
178
- score: CfsScoreSchema.optional(),
179
- venue: CfsVenueSchema.optional(),
180
- round: z.object({
181
- name: z.string(),
182
- roundId: z.string(),
183
- roundNumber: z.number()
184
- }).passthrough().optional()
185
- }).passthrough();
186
- var MatchItemListSchema = z.object({
187
- roundId: z.string().optional(),
188
- items: z.array(MatchItemSchema)
189
- }).passthrough();
190
- var CfsPlayerInnerSchema = z.object({
191
- playerId: z.string(),
192
- playerName: z.object({
193
- givenName: z.string(),
194
- surname: z.string()
195
- }).passthrough(),
196
- captain: z.boolean().optional(),
197
- playerJumperNumber: z.number().optional()
198
- }).passthrough();
199
- var PlayerGameStatsSchema = z.object({
200
- goals: z.number().optional(),
201
- behinds: z.number().optional(),
202
- kicks: z.number().optional(),
203
- handballs: z.number().optional(),
204
- disposals: z.number().optional(),
205
- marks: z.number().optional(),
206
- bounces: z.number().optional(),
207
- tackles: z.number().optional(),
208
- contestedPossessions: z.number().optional(),
209
- uncontestedPossessions: z.number().optional(),
210
- totalPossessions: z.number().optional(),
211
- inside50s: z.number().optional(),
212
- marksInside50: z.number().optional(),
213
- contestedMarks: z.number().optional(),
214
- hitouts: z.number().optional(),
215
- onePercenters: z.number().optional(),
216
- disposalEfficiency: z.number().optional(),
217
- clangers: z.number().optional(),
218
- freesFor: z.number().optional(),
219
- freesAgainst: z.number().optional(),
220
- dreamTeamPoints: z.number().optional(),
221
- clearances: z.object({
222
- centreClearances: z.number().optional(),
223
- stoppageClearances: z.number().optional(),
224
- totalClearances: z.number().optional()
225
- }).passthrough().optional(),
226
- rebound50s: z.number().optional(),
227
- goalAssists: z.number().optional(),
228
- goalAccuracy: z.number().optional(),
229
- turnovers: z.number().optional(),
230
- intercepts: z.number().optional(),
231
- tacklesInside50: z.number().optional(),
232
- shotsAtGoal: z.number().optional(),
233
- metresGained: z.number().optional(),
234
- scoreInvolvements: z.number().optional(),
235
- ratingPoints: z.number().optional(),
236
- extendedStats: z.object({
237
- effectiveDisposals: z.number().optional(),
238
- effectiveKicks: z.number().optional(),
239
- kickEfficiency: z.number().optional(),
240
- kickToHandballRatio: z.number().optional(),
241
- pressureActs: z.number().optional(),
242
- defHalfPressureActs: z.number().optional(),
243
- spoils: z.number().optional(),
244
- hitoutsToAdvantage: z.number().optional(),
245
- hitoutWinPercentage: z.number().optional(),
246
- hitoutToAdvantageRate: z.number().optional(),
247
- groundBallGets: z.number().optional(),
248
- f50GroundBallGets: z.number().optional(),
249
- interceptMarks: z.number().optional(),
250
- marksOnLead: z.number().optional(),
251
- contestedPossessionRate: z.number().optional(),
252
- contestOffOneOnOnes: z.number().optional(),
253
- contestOffWins: z.number().optional(),
254
- contestOffWinsPercentage: z.number().optional(),
255
- contestDefOneOnOnes: z.number().optional(),
256
- contestDefLosses: z.number().optional(),
257
- contestDefLossPercentage: z.number().optional(),
258
- centreBounceAttendances: z.number().optional(),
259
- kickins: z.number().optional(),
260
- kickinsPlayon: z.number().optional(),
261
- ruckContests: z.number().optional(),
262
- scoreLaunches: z.number().optional()
263
- }).passthrough().optional()
264
- }).passthrough();
265
- var PlayerStatsItemSchema = z.object({
266
- player: z.object({
267
- player: z.object({
268
- position: z.string().optional(),
269
- player: CfsPlayerInnerSchema
270
- }).passthrough(),
271
- jumperNumber: z.number().optional()
272
- }).passthrough(),
273
- teamId: z.string(),
274
- playerStats: z.object({
275
- stats: PlayerGameStatsSchema,
276
- timeOnGroundPercentage: z.number().optional()
277
- }).passthrough()
278
- }).passthrough();
279
- var PlayerStatsListSchema = z.object({
280
- homeTeamPlayerStats: z.array(PlayerStatsItemSchema),
281
- awayTeamPlayerStats: z.array(PlayerStatsItemSchema)
282
- }).passthrough();
283
- var RosterPlayerSchema = z.object({
284
- player: z.object({
285
- position: z.string().optional(),
286
- player: CfsPlayerInnerSchema
287
- }).passthrough(),
288
- jumperNumber: z.number().optional()
289
- }).passthrough();
290
- var TeamPlayersSchema = z.object({
291
- teamId: z.string(),
292
- players: z.array(RosterPlayerSchema)
293
- }).passthrough();
294
- var MatchRosterSchema = z.object({
295
- match: CfsMatchSchema,
296
- teamPlayers: z.array(TeamPlayersSchema)
297
- }).passthrough();
298
- var TeamItemSchema = z.object({
299
- id: z.number(),
300
- name: z.string(),
301
- abbreviation: z.string().optional(),
302
- teamType: z.string().optional()
303
- }).passthrough();
304
- var TeamListSchema = z.object({
305
- teams: z.array(TeamItemSchema)
306
- }).passthrough();
307
- var SquadPlayerInnerSchema = z.object({
308
- id: z.number(),
309
- providerId: z.string().optional(),
310
- firstName: z.string(),
311
- surname: z.string(),
312
- dateOfBirth: z.string().optional(),
313
- heightInCm: z.number().optional(),
314
- weightInKg: z.number().optional(),
315
- draftYear: z.string().optional(),
316
- draftPosition: z.string().optional(),
317
- draftType: z.string().optional(),
318
- debutYear: z.string().optional(),
319
- recruitedFrom: z.string().optional()
320
- }).passthrough();
321
- var SquadPlayerItemSchema = z.object({
322
- player: SquadPlayerInnerSchema,
323
- jumperNumber: z.number().optional(),
324
- position: z.string().optional()
325
- }).passthrough();
326
- var SquadSchema = z.object({
327
- team: z.object({
328
- name: z.string()
329
- }).passthrough().optional(),
330
- players: z.array(SquadPlayerItemSchema)
331
- }).passthrough();
332
- var SquadListSchema = z.object({
333
- squad: SquadSchema
334
- }).passthrough();
335
- var WinLossRecordSchema = z.object({
336
- wins: z.number(),
337
- losses: z.number(),
338
- draws: z.number(),
339
- played: z.number().optional()
340
- }).passthrough();
341
- var LadderEntryRawSchema = z.object({
342
- position: z.number(),
343
- team: z.object({
344
- name: z.string(),
345
- id: z.number().optional(),
346
- abbreviation: z.string().optional()
347
- }).passthrough(),
348
- played: z.number().optional(),
349
- pointsFor: z.number().optional(),
350
- pointsAgainst: z.number().optional(),
351
- thisSeasonRecord: z.object({
352
- aggregatePoints: z.number().optional(),
353
- percentage: z.number().optional(),
354
- winLossRecord: WinLossRecordSchema.optional()
355
- }).passthrough().optional(),
356
- form: z.string().optional()
357
- }).passthrough();
358
- var LadderResponseSchema = z.object({
359
- ladders: z.array(
360
- z.object({
361
- entries: z.array(LadderEntryRawSchema)
362
- }).passthrough()
363
- ),
364
- round: z.object({
365
- roundNumber: z.number(),
366
- name: z.string().optional()
367
- }).passthrough().optional()
368
- }).passthrough();
232
+ // src/transforms/footywire-player-stats.ts
233
+ import * as cheerio from "cheerio";
369
234
 
370
- // src/sources/afl-api.ts
371
- var TOKEN_URL = "https://api.afl.com.au/cfs/afl/WMCTok";
372
- var API_BASE = "https://aflapi.afl.com.au/afl/v2";
373
- var CFS_BASE = "https://api.afl.com.au/cfs/afl";
374
- var AflApiClient = class {
375
- fetchFn;
376
- tokenUrl;
377
- cachedToken = null;
378
- constructor(options) {
379
- this.fetchFn = options?.fetchFn ?? globalThis.fetch;
380
- this.tokenUrl = options?.tokenUrl ?? TOKEN_URL;
381
- }
382
- /**
383
- * Authenticate with the WMCTok token endpoint and cache the token.
384
- *
385
- * @returns The access token on success, or an error Result.
386
- */
387
- async authenticate() {
388
- try {
389
- const response = await this.fetchFn(this.tokenUrl, {
390
- method: "POST",
391
- headers: { "Content-Length": "0" }
392
- });
393
- if (!response.ok) {
394
- return err(new AflApiError(`Token request failed: ${response.status}`, response.status));
395
- }
396
- const json = await response.json();
397
- const parsed = AflApiTokenSchema.safeParse(json);
398
- if (!parsed.success) {
399
- return err(new AflApiError("Invalid token response format"));
235
+ // src/lib/parse-utils.ts
236
+ function safeInt(text) {
237
+ const cleaned = text.replace(/[^0-9-]/g, "").trim();
238
+ if (!cleaned) return null;
239
+ const n = Number.parseInt(cleaned, 10);
240
+ return Number.isNaN(n) ? null : n;
241
+ }
242
+ function parseIntOr0(text) {
243
+ const n = Number.parseInt(text.replace(/[^0-9-]/g, ""), 10);
244
+ return Number.isNaN(n) ? 0 : n;
245
+ }
246
+ function parseFloatOr0(text) {
247
+ const n = Number.parseFloat(text.replace(/[^0-9.-]/g, ""));
248
+ return Number.isNaN(n) ? 0 : n;
249
+ }
250
+
251
+ // src/transforms/footywire-player-stats.ts
252
+ var BASIC_COLS = [
253
+ "Player",
254
+ "K",
255
+ "HB",
256
+ "D",
257
+ "M",
258
+ "G",
259
+ "B",
260
+ "T",
261
+ "HO",
262
+ "GA",
263
+ "I50",
264
+ "CL",
265
+ "CG",
266
+ "R50",
267
+ "FF",
268
+ "FA",
269
+ "AF",
270
+ "SC"
271
+ ];
272
+ var ADVANCED_COLS = [
273
+ "Player",
274
+ "CP",
275
+ "UP",
276
+ "ED",
277
+ "DE",
278
+ "CM",
279
+ "GA",
280
+ "MI5",
281
+ "1%",
282
+ "BO",
283
+ "CCL",
284
+ "SCL",
285
+ "SI",
286
+ "MG",
287
+ "TO",
288
+ "ITC",
289
+ "T5",
290
+ "TOG"
291
+ ];
292
+ function cleanPlayerName(raw) {
293
+ return raw.replace(/[↗↙]/g, "").trim();
294
+ }
295
+ function parseStatsTable(html, expectedCols, rowParser) {
296
+ const $ = cheerio.load(html);
297
+ const results = [];
298
+ $("table").each((_i, table) => {
299
+ const rows = $(table).find("tr");
300
+ if (rows.length < 3) return;
301
+ const headerCells = $(rows[0]).find("td, th").map((_, c) => $(c).text().trim()).get();
302
+ if (headerCells[0] !== "Player" || headerCells.length < expectedCols.length) return;
303
+ if (!headerCells.includes(expectedCols[1])) return;
304
+ let teamName = "";
305
+ const parentTable = $(table).closest("table").parent().closest("table");
306
+ const teamHeader = parentTable.find("td:contains('Match Statistics')").first();
307
+ if (teamHeader.length > 0) {
308
+ const headerText = teamHeader.text().trim();
309
+ const match = /^(\w[\w\s]+?)\s+Match Statistics/i.exec(headerText);
310
+ if (match?.[1]) {
311
+ teamName = match[1].trim();
400
312
  }
401
- const ttlMs = 30 * 60 * 1e3;
402
- this.cachedToken = {
403
- accessToken: parsed.data.token,
404
- expiresAt: Date.now() + ttlMs
405
- };
406
- return ok(parsed.data.token);
407
- } catch (cause) {
408
- return err(
409
- new AflApiError(
410
- `Token request failed: ${cause instanceof Error ? cause.message : String(cause)}`
411
- )
412
- );
413
313
  }
414
- }
415
- /**
416
- * Whether the cached token is still valid (not expired).
417
- */
418
- get isAuthenticated() {
419
- return this.cachedToken !== null && Date.now() < this.cachedToken.expiresAt;
420
- }
421
- /**
422
- * Perform an authenticated fetch, automatically adding the bearer token.
423
- * Retries once on 401 by re-authenticating.
424
- *
425
- * @param url - The URL to fetch.
426
- * @param init - Additional fetch options.
427
- * @returns The Response on success, or an error Result.
428
- */
429
- async authedFetch(url, init) {
430
- if (!this.isAuthenticated) {
431
- const authResult = await this.authenticate();
432
- if (!authResult.success) {
433
- return authResult;
434
- }
314
+ const parsed = [];
315
+ rows.each((j, row) => {
316
+ if (j === 0) return;
317
+ const cells = $(row).find("td").map((_, c) => $(c).text().trim()).get();
318
+ if (cells.length < expectedCols.length - 1) return;
319
+ const result = rowParser(cells);
320
+ if (result) parsed.push(result);
321
+ });
322
+ if (parsed.length > 0) {
323
+ results.push([teamName, parsed]);
435
324
  }
436
- const doFetch = async () => {
437
- const token = this.cachedToken;
438
- if (!token) {
439
- throw new AflApiError("No cached token available");
440
- }
441
- const headers = new Headers(init?.headers);
442
- headers.set("x-media-mis-token", token.accessToken);
443
- return this.fetchFn(url, { ...init, headers });
444
- };
445
- try {
446
- let response = await doFetch();
447
- if (response.status === 401) {
448
- const authResult = await this.authenticate();
449
- if (!authResult.success) {
450
- return authResult;
451
- }
452
- response = await doFetch();
453
- }
454
- if (!response.ok) {
455
- return err(
456
- new AflApiError(
457
- `Request failed: ${response.status} ${response.statusText}`,
458
- response.status
459
- )
460
- );
461
- }
462
- return ok(response);
463
- } catch (cause) {
464
- return err(
465
- new AflApiError(
466
- `Request failed: ${cause instanceof Error ? cause.message : String(cause)}`
467
- )
468
- );
325
+ });
326
+ return results;
327
+ }
328
+ function parseBasicRow(cells) {
329
+ const player = cleanPlayerName(cells[0] ?? "");
330
+ if (!player) return null;
331
+ return {
332
+ player,
333
+ kicks: parseIntOr0(cells[1] ?? "0"),
334
+ handballs: parseIntOr0(cells[2] ?? "0"),
335
+ disposals: parseIntOr0(cells[3] ?? "0"),
336
+ marks: parseIntOr0(cells[4] ?? "0"),
337
+ goals: parseIntOr0(cells[5] ?? "0"),
338
+ behinds: parseIntOr0(cells[6] ?? "0"),
339
+ tackles: parseIntOr0(cells[7] ?? "0"),
340
+ hitouts: parseIntOr0(cells[8] ?? "0"),
341
+ goalAssists: parseIntOr0(cells[9] ?? "0"),
342
+ inside50s: parseIntOr0(cells[10] ?? "0"),
343
+ clearances: parseIntOr0(cells[11] ?? "0"),
344
+ clangers: parseIntOr0(cells[12] ?? "0"),
345
+ rebound50s: parseIntOr0(cells[13] ?? "0"),
346
+ freesFor: parseIntOr0(cells[14] ?? "0"),
347
+ freesAgainst: parseIntOr0(cells[15] ?? "0"),
348
+ dreamTeamPoints: parseIntOr0(cells[16] ?? "0"),
349
+ supercoachPoints: parseIntOr0(cells[17] ?? "0")
350
+ };
351
+ }
352
+ function parseAdvancedRow(cells) {
353
+ const player = cleanPlayerName(cells[0] ?? "");
354
+ if (!player) return null;
355
+ return {
356
+ player,
357
+ contestedPossessions: parseIntOr0(cells[1] ?? "0"),
358
+ uncontestedPossessions: parseIntOr0(cells[2] ?? "0"),
359
+ effectiveDisposals: parseIntOr0(cells[3] ?? "0"),
360
+ disposalEfficiency: parseFloatOr0(cells[4] ?? "0"),
361
+ contestedMarks: parseIntOr0(cells[5] ?? "0"),
362
+ goalAssists: parseIntOr0(cells[6] ?? "0"),
363
+ marksInside50: parseIntOr0(cells[7] ?? "0"),
364
+ onePercenters: parseIntOr0(cells[8] ?? "0"),
365
+ bounces: parseIntOr0(cells[9] ?? "0"),
366
+ centreClearances: parseIntOr0(cells[10] ?? "0"),
367
+ stoppageClearances: parseIntOr0(cells[11] ?? "0"),
368
+ scoreInvolvements: parseIntOr0(cells[12] ?? "0"),
369
+ metresGained: parseIntOr0(cells[13] ?? "0"),
370
+ turnovers: parseIntOr0(cells[14] ?? "0"),
371
+ intercepts: parseIntOr0(cells[15] ?? "0"),
372
+ tacklesInside50: parseIntOr0(cells[16] ?? "0"),
373
+ timeOnGroundPercentage: parseFloatOr0(cells[17] ?? "0")
374
+ };
375
+ }
376
+ function parseBasicStats(html) {
377
+ return parseStatsTable(html, [...BASIC_COLS], parseBasicRow);
378
+ }
379
+ function parseAdvancedStats(html) {
380
+ return parseStatsTable(html, [...ADVANCED_COLS], parseAdvancedRow);
381
+ }
382
+ function mergeFootyWireStats(basicTeams, advancedTeams, matchId, season, roundNumber) {
383
+ const stats = [];
384
+ for (let teamIdx = 0; teamIdx < basicTeams.length; teamIdx++) {
385
+ const basicEntry = basicTeams[teamIdx];
386
+ const advancedEntry = advancedTeams[teamIdx];
387
+ if (!basicEntry) continue;
388
+ const [teamName, basicRows] = basicEntry;
389
+ const advancedRows = advancedEntry?.[1] ?? [];
390
+ const advancedByName = /* @__PURE__ */ new Map();
391
+ for (const adv of advancedRows) {
392
+ advancedByName.set(adv.player.toLowerCase(), adv);
469
393
  }
394
+ for (const basic of basicRows) {
395
+ const nameParts = basic.player.split(/\s+/);
396
+ const surname = nameParts[nameParts.length - 1] ?? "";
397
+ const firstName = nameParts.slice(0, -1).join(" ");
398
+ const initial = firstName.charAt(0);
399
+ const abbrevName = `${initial} ${surname}`.toLowerCase();
400
+ const adv = advancedByName.get(abbrevName);
401
+ stats.push({
402
+ matchId: `FW_${matchId}`,
403
+ season,
404
+ roundNumber,
405
+ team: teamName,
406
+ competition: "AFLM",
407
+ playerId: `FW_${basic.player.replace(/\s+/g, "_")}`,
408
+ givenName: firstName,
409
+ surname,
410
+ displayName: basic.player,
411
+ jumperNumber: null,
412
+ kicks: basic.kicks,
413
+ handballs: basic.handballs,
414
+ disposals: basic.disposals,
415
+ marks: basic.marks,
416
+ goals: basic.goals,
417
+ behinds: basic.behinds,
418
+ tackles: basic.tackles,
419
+ hitouts: basic.hitouts,
420
+ freesFor: basic.freesFor,
421
+ freesAgainst: basic.freesAgainst,
422
+ contestedPossessions: adv?.contestedPossessions ?? null,
423
+ uncontestedPossessions: adv?.uncontestedPossessions ?? null,
424
+ contestedMarks: adv?.contestedMarks ?? null,
425
+ intercepts: adv?.intercepts ?? null,
426
+ centreClearances: adv?.centreClearances ?? null,
427
+ stoppageClearances: adv?.stoppageClearances ?? null,
428
+ totalClearances: basic.clearances,
429
+ inside50s: basic.inside50s,
430
+ rebound50s: basic.rebound50s,
431
+ clangers: basic.clangers,
432
+ turnovers: adv?.turnovers ?? null,
433
+ onePercenters: adv?.onePercenters ?? null,
434
+ bounces: adv?.bounces ?? null,
435
+ goalAssists: basic.goalAssists,
436
+ disposalEfficiency: adv?.disposalEfficiency ?? null,
437
+ metresGained: adv?.metresGained ?? null,
438
+ goalAccuracy: null,
439
+ marksInside50: adv?.marksInside50 ?? null,
440
+ tacklesInside50: adv?.tacklesInside50 ?? null,
441
+ shotsAtGoal: null,
442
+ scoreInvolvements: adv?.scoreInvolvements ?? null,
443
+ totalPossessions: null,
444
+ timeOnGroundPercentage: adv?.timeOnGroundPercentage ?? null,
445
+ ratingPoints: null,
446
+ dreamTeamPoints: basic.dreamTeamPoints,
447
+ effectiveDisposals: adv?.effectiveDisposals ?? null,
448
+ effectiveKicks: null,
449
+ kickEfficiency: null,
450
+ kickToHandballRatio: null,
451
+ pressureActs: null,
452
+ defHalfPressureActs: null,
453
+ spoils: null,
454
+ hitoutsToAdvantage: null,
455
+ hitoutWinPercentage: null,
456
+ hitoutToAdvantageRate: null,
457
+ groundBallGets: null,
458
+ f50GroundBallGets: null,
459
+ interceptMarks: null,
460
+ marksOnLead: null,
461
+ contestedPossessionRate: null,
462
+ contestOffOneOnOnes: null,
463
+ contestOffWins: null,
464
+ contestOffWinsPercentage: null,
465
+ contestDefOneOnOnes: null,
466
+ contestDefLosses: null,
467
+ contestDefLossPercentage: null,
468
+ centreBounceAttendances: null,
469
+ kickins: null,
470
+ kickinsPlayon: null,
471
+ ruckContests: null,
472
+ scoreLaunches: null,
473
+ source: "footywire"
474
+ });
475
+ }
476
+ }
477
+ return stats;
478
+ }
479
+
480
+ // src/transforms/match-results.ts
481
+ var FINALS_PATTERN = /final|elimination|qualifying|preliminary|semi|grand/i;
482
+ function inferRoundType(roundName) {
483
+ return FINALS_PATTERN.test(roundName) ? "Finals" : "HomeAndAway";
484
+ }
485
+ function toMatchStatus(raw) {
486
+ switch (raw) {
487
+ case "CONCLUDED":
488
+ case "COMPLETE":
489
+ return "Complete";
490
+ case "LIVE":
491
+ case "IN_PROGRESS":
492
+ return "Live";
493
+ case "UPCOMING":
494
+ case "SCHEDULED":
495
+ return "Upcoming";
496
+ case "POSTPONED":
497
+ return "Postponed";
498
+ case "CANCELLED":
499
+ return "Cancelled";
500
+ default:
501
+ return "Complete";
502
+ }
503
+ }
504
+ function toQuarterScore(period) {
505
+ return {
506
+ goals: period.score.goals,
507
+ behinds: period.score.behinds,
508
+ points: period.score.totalScore
509
+ };
510
+ }
511
+ function findPeriod(periods, quarter) {
512
+ if (!periods) return null;
513
+ const period = periods.find((p) => p.periodNumber === quarter);
514
+ return period ? toQuarterScore(period) : null;
515
+ }
516
+ function transformMatchItems(items, season, competition, source = "afl-api") {
517
+ return items.map((item) => {
518
+ const homeScore = item.score?.homeTeamScore;
519
+ const awayScore = item.score?.awayTeamScore;
520
+ const homePoints = homeScore?.matchScore.totalScore ?? 0;
521
+ const awayPoints = awayScore?.matchScore.totalScore ?? 0;
522
+ return {
523
+ matchId: item.match.matchId,
524
+ season,
525
+ roundNumber: item.round?.roundNumber ?? 0,
526
+ roundType: inferRoundType(item.round?.name ?? ""),
527
+ date: new Date(item.match.utcStartTime),
528
+ venue: item.venue?.name ?? "",
529
+ homeTeam: normaliseTeamName(item.match.homeTeam.name),
530
+ awayTeam: normaliseTeamName(item.match.awayTeam.name),
531
+ homeGoals: homeScore?.matchScore.goals ?? 0,
532
+ homeBehinds: homeScore?.matchScore.behinds ?? 0,
533
+ homePoints,
534
+ awayGoals: awayScore?.matchScore.goals ?? 0,
535
+ awayBehinds: awayScore?.matchScore.behinds ?? 0,
536
+ awayPoints,
537
+ margin: homePoints - awayPoints,
538
+ q1Home: findPeriod(homeScore?.periodScore, 1),
539
+ q2Home: findPeriod(homeScore?.periodScore, 2),
540
+ q3Home: findPeriod(homeScore?.periodScore, 3),
541
+ q4Home: findPeriod(homeScore?.periodScore, 4),
542
+ q1Away: findPeriod(awayScore?.periodScore, 1),
543
+ q2Away: findPeriod(awayScore?.periodScore, 2),
544
+ q3Away: findPeriod(awayScore?.periodScore, 3),
545
+ q4Away: findPeriod(awayScore?.periodScore, 4),
546
+ status: toMatchStatus(item.match.status),
547
+ attendance: null,
548
+ venueState: item.venue?.state ?? null,
549
+ venueTimezone: item.venue?.timeZone ?? null,
550
+ homeRushedBehinds: homeScore?.rushedBehinds ?? null,
551
+ awayRushedBehinds: awayScore?.rushedBehinds ?? null,
552
+ homeMinutesInFront: homeScore?.minutesInFront ?? null,
553
+ awayMinutesInFront: awayScore?.minutesInFront ?? null,
554
+ source,
555
+ competition
556
+ };
557
+ });
558
+ }
559
+
560
+ // src/sources/footywire.ts
561
+ var FOOTYWIRE_BASE = "https://www.footywire.com/afl/footy";
562
+ var FootyWireClient = class {
563
+ fetchFn;
564
+ constructor(options) {
565
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch;
470
566
  }
471
567
  /**
472
- * Fetch JSON from a URL, validate with a Zod schema, and return a typed Result.
568
+ * Fetch the HTML content of any URL using this client's fetch function.
473
569
  *
474
- * @param url - The URL to fetch.
475
- * @param schema - Zod schema to validate the response against.
476
- * @returns Validated data on success, or an error Result.
570
+ * Public wrapper around the internal fetchHtml for use by external modules
571
+ * (e.g. awards) that need to scrape FootyWire pages.
477
572
  */
478
- async fetchJson(url, schema) {
479
- const isPublic = url.startsWith(API_BASE);
480
- let response;
481
- if (isPublic) {
482
- try {
483
- response = await this.fetchFn(url);
484
- if (!response.ok) {
485
- return err(
486
- new AflApiError(
487
- `Request failed: ${response.status} ${response.statusText}`,
488
- response.status
489
- )
490
- );
491
- }
492
- } catch (cause) {
493
- return err(
494
- new AflApiError(
495
- `Request failed: ${cause instanceof Error ? cause.message : String(cause)}`
496
- )
497
- );
498
- }
499
- } else {
500
- const fetchResult = await this.authedFetch(url);
501
- if (!fetchResult.success) {
502
- return fetchResult;
503
- }
504
- response = fetchResult.data;
505
- }
573
+ async fetchPage(url) {
574
+ return this.fetchHtml(url);
575
+ }
576
+ /**
577
+ * Fetch the HTML content of a FootyWire page.
578
+ */
579
+ async fetchHtml(url) {
506
580
  try {
507
- const json = await response.json();
508
- const parsed = schema.safeParse(json);
509
- if (!parsed.success) {
581
+ const response = await this.fetchFn(url, {
582
+ headers: {
583
+ "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"
584
+ }
585
+ });
586
+ if (!response.ok) {
510
587
  return err(
511
- new ValidationError("Response validation failed", [
512
- { path: url, message: String(parsed.error) }
513
- ])
588
+ new ScrapeError(`FootyWire request failed: ${response.status} (${url})`, "footywire")
514
589
  );
515
590
  }
516
- return ok(parsed.data);
591
+ const html = await response.text();
592
+ return ok(html);
517
593
  } catch (cause) {
518
594
  return err(
519
- new AflApiError(
520
- `JSON parse failed: ${cause instanceof Error ? cause.message : String(cause)}`
595
+ new ScrapeError(
596
+ `FootyWire request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
597
+ "footywire"
521
598
  )
522
599
  );
523
600
  }
524
601
  }
525
602
  /**
526
- * Resolve a competition code (e.g. "AFLM") to its API competition ID.
603
+ * Fetch season match results from FootyWire.
527
604
  *
528
- * @param code - The competition code to resolve.
529
- * @returns The competition ID string on success.
605
+ * @param year - The season year.
606
+ * @returns Array of match results.
530
607
  */
531
- async resolveCompetitionId(code) {
532
- const result = await this.fetchJson(
533
- `${API_BASE}/competitions?pageSize=50`,
534
- CompetitionListSchema
535
- );
536
- if (!result.success) {
537
- return result;
608
+ async fetchSeasonResults(year) {
609
+ const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
610
+ const htmlResult = await this.fetchHtml(url);
611
+ if (!htmlResult.success) {
612
+ return htmlResult;
538
613
  }
539
- const apiCode = code === "AFLM" ? "AFL" : code;
540
- const competition = result.data.competitions.find((c) => c.code === apiCode);
541
- if (!competition) {
542
- return err(new AflApiError(`Competition not found for code: ${code}`));
614
+ try {
615
+ const results = parseMatchList(htmlResult.data, year);
616
+ return ok(results);
617
+ } catch (cause) {
618
+ return err(
619
+ new ScrapeError(
620
+ `Failed to parse match list: ${cause instanceof Error ? cause.message : String(cause)}`,
621
+ "footywire"
622
+ )
623
+ );
543
624
  }
544
- return ok(competition.id);
545
625
  }
546
626
  /**
547
- * Resolve a season (compseason) ID from a competition ID and year.
627
+ * Fetch player statistics for a single match.
548
628
  *
549
- * @param competitionId - The competition ID (from {@link resolveCompetitionId}).
550
- * @param year - The season year (e.g. 2024).
551
- * @returns The compseason ID string on success.
629
+ * Scrapes both the basic and advanced stats pages.
630
+ * Available from 2010 onwards.
631
+ *
632
+ * @param matchId - The FootyWire match ID (numeric string).
633
+ * @param season - The season year.
634
+ * @param roundNumber - The round number.
552
635
  */
553
- async resolveSeasonId(competitionId, year) {
554
- const result = await this.fetchJson(
555
- `${API_BASE}/competitions/${competitionId}/compseasons?pageSize=100`,
556
- CompseasonListSchema
557
- );
558
- if (!result.success) {
559
- return result;
560
- }
561
- const yearStr = String(year);
562
- const season = result.data.compSeasons.find((cs) => cs.name.includes(yearStr));
563
- if (!season) {
564
- return err(new AflApiError(`Season not found for year: ${year}`));
636
+ async fetchMatchPlayerStats(matchId, season, roundNumber) {
637
+ const basicUrl = `${FOOTYWIRE_BASE}/ft_match_statistics?mid=${matchId}`;
638
+ const advancedUrl = `${FOOTYWIRE_BASE}/ft_match_statistics?mid=${matchId}&advv=Y`;
639
+ const basicResult = await this.fetchHtml(basicUrl);
640
+ if (!basicResult.success) return basicResult;
641
+ const advancedResult = await this.fetchHtml(advancedUrl);
642
+ if (!advancedResult.success) return advancedResult;
643
+ try {
644
+ const basicTeams = parseBasicStats(basicResult.data);
645
+ const advancedTeams = parseAdvancedStats(advancedResult.data);
646
+ const stats = mergeFootyWireStats(basicTeams, advancedTeams, matchId, season, roundNumber);
647
+ return ok(stats);
648
+ } catch (cause) {
649
+ return err(
650
+ new ScrapeError(
651
+ `Failed to parse match stats: ${cause instanceof Error ? cause.message : String(cause)}`,
652
+ "footywire"
653
+ )
654
+ );
565
655
  }
566
- return ok(season.id);
567
656
  }
568
657
  /**
569
- * Resolve a season ID from a competition code and year in one step.
658
+ * Fetch match IDs from a season's match list page.
570
659
  *
571
- * @param code - The competition code (e.g. "AFLM").
572
- * @param year - The season year (e.g. 2025).
573
- * @returns The compseason ID on success.
660
+ * Extracts `mid=XXXX` values from score links.
661
+ *
662
+ * @param year - The season year.
663
+ * @returns Array of match ID strings.
574
664
  */
575
- async resolveCompSeason(code, year) {
576
- const compResult = await this.resolveCompetitionId(code);
577
- if (!compResult.success) return compResult;
578
- return this.resolveSeasonId(compResult.data, year);
665
+ async fetchSeasonMatchIds(year) {
666
+ const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
667
+ const htmlResult = await this.fetchHtml(url);
668
+ if (!htmlResult.success) return htmlResult;
669
+ try {
670
+ const $ = cheerio2.load(htmlResult.data);
671
+ const ids = [];
672
+ $(".data:nth-child(5) a").each((_i, el) => {
673
+ const href = $(el).attr("href") ?? "";
674
+ const match = /mid=(\d+)/.exec(href);
675
+ if (match?.[1]) {
676
+ ids.push(match[1]);
677
+ }
678
+ });
679
+ return ok(ids);
680
+ } catch (cause) {
681
+ return err(
682
+ new ScrapeError(
683
+ `Failed to parse match IDs: ${cause instanceof Error ? cause.message : String(cause)}`,
684
+ "footywire"
685
+ )
686
+ );
687
+ }
579
688
  }
580
689
  /**
581
- * Fetch all rounds for a season with their metadata.
690
+ * Fetch player list (team history) from FootyWire.
582
691
  *
583
- * @param seasonId - The compseason ID (from {@link resolveSeasonId}).
584
- * @returns Array of round objects on success.
692
+ * Scrapes the team history page (e.g. `th-hawthorn-hawks`) which lists
693
+ * all players who have played for that team.
694
+ *
695
+ * @param teamName - Canonical team name (e.g. "Hawthorn").
696
+ * @returns Array of player details (without source/competition fields).
585
697
  */
586
- async resolveRounds(seasonId) {
587
- const result = await this.fetchJson(
588
- `${API_BASE}/compseasons/${seasonId}/rounds?pageSize=50`,
589
- RoundListSchema
590
- );
591
- if (!result.success) {
592
- return result;
698
+ async fetchPlayerList(teamName) {
699
+ const slug = teamNameToFootyWireSlug(teamName);
700
+ if (!slug) {
701
+ return err(new ScrapeError(`No FootyWire slug mapping for team: ${teamName}`, "footywire"));
702
+ }
703
+ const url = `${FOOTYWIRE_BASE}/tp-${slug}`;
704
+ const htmlResult = await this.fetchHtml(url);
705
+ if (!htmlResult.success) return htmlResult;
706
+ try {
707
+ const players = parseFootyWirePlayerList(htmlResult.data, teamName);
708
+ return ok(players);
709
+ } catch (cause) {
710
+ return err(
711
+ new ScrapeError(
712
+ `Failed to parse player list: ${cause instanceof Error ? cause.message : String(cause)}`,
713
+ "footywire"
714
+ )
715
+ );
593
716
  }
594
- return ok(result.data.rounds);
595
717
  }
596
718
  /**
597
- * Fetch match items for a round using the /cfs/ endpoint.
719
+ * Fetch fixture data from FootyWire.
598
720
  *
599
- * @param roundProviderId - The round provider ID (e.g. "CD_R202501401").
600
- * @returns Array of match items on success.
721
+ * Parses the match list page to extract scheduled matches with dates and venues.
722
+ *
723
+ * @param year - The season year.
724
+ * @returns Array of fixture entries.
601
725
  */
602
- async fetchRoundMatchItems(roundProviderId) {
603
- const result = await this.fetchJson(
604
- `${CFS_BASE}/matchItems/round/${roundProviderId}`,
605
- MatchItemListSchema
606
- );
607
- if (!result.success) {
608
- return result;
726
+ async fetchSeasonFixture(year) {
727
+ const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
728
+ const htmlResult = await this.fetchHtml(url);
729
+ if (!htmlResult.success) return htmlResult;
730
+ try {
731
+ const fixtures = parseFixtureList(htmlResult.data, year);
732
+ return ok(fixtures);
733
+ } catch (cause) {
734
+ return err(
735
+ new ScrapeError(
736
+ `Failed to parse fixture list: ${cause instanceof Error ? cause.message : String(cause)}`,
737
+ "footywire"
738
+ )
739
+ );
609
740
  }
610
- return ok(result.data.items);
611
741
  }
612
742
  /**
613
- * Fetch match items for a round by resolving the round provider ID from season and round number.
743
+ * Fetch team statistics from FootyWire.
614
744
  *
615
- * @param seasonId - The compseason ID.
616
- * @param roundNumber - The round number.
617
- * @returns Array of match items on success.
745
+ * Scrapes team-level aggregate stats (totals or averages) for a season.
746
+ *
747
+ * @param year - The season year.
748
+ * @param summaryType - "totals" or "averages" (default "totals").
749
+ * @returns Array of team stats entries.
618
750
  */
619
- async fetchRoundMatchItemsByNumber(seasonId, roundNumber) {
620
- const roundsResult = await this.resolveRounds(seasonId);
621
- if (!roundsResult.success) {
622
- return roundsResult;
751
+ async fetchTeamStats(year, summaryType = "totals") {
752
+ const teamType = summaryType === "averages" ? "TA" : "TT";
753
+ const oppType = summaryType === "averages" ? "OA" : "OT";
754
+ const teamUrl = `${FOOTYWIRE_BASE}/ft_team_rankings?year=${year}&type=${teamType}&sby=2`;
755
+ const oppUrl = `${FOOTYWIRE_BASE}/ft_team_rankings?year=${year}&type=${oppType}&sby=2`;
756
+ const [teamResult, oppResult] = await Promise.all([
757
+ this.fetchHtml(teamUrl),
758
+ this.fetchHtml(oppUrl)
759
+ ]);
760
+ if (!teamResult.success) return teamResult;
761
+ if (!oppResult.success) return oppResult;
762
+ try {
763
+ const teamStats = parseFootyWireTeamStats(teamResult.data, year, "for");
764
+ const oppStats = parseFootyWireTeamStats(oppResult.data, year, "against");
765
+ const merged = mergeTeamAndOppStats(teamStats, oppStats);
766
+ return ok(merged);
767
+ } catch (cause) {
768
+ return err(
769
+ new ScrapeError(
770
+ `Failed to parse team stats: ${cause instanceof Error ? cause.message : String(cause)}`,
771
+ "footywire"
772
+ )
773
+ );
623
774
  }
624
- const round = roundsResult.data.find((r) => r.roundNumber === roundNumber);
625
- if (!round?.providerId) {
626
- return err(new AflApiError(`Round not found or missing providerId: round ${roundNumber}`));
775
+ }
776
+ };
777
+ function parseMatchList(html, year) {
778
+ const $ = cheerio2.load(html);
779
+ const results = [];
780
+ let currentRound = 0;
781
+ let currentRoundType = "HomeAndAway";
782
+ $("tr").each((_i, row) => {
783
+ const roundHeader = $(row).find("td[colspan='7']");
784
+ if (roundHeader.length > 0) {
785
+ const text = roundHeader.text().trim();
786
+ currentRoundType = inferRoundType(text);
787
+ const roundMatch = /Round\s+(\d+)/i.exec(text);
788
+ if (roundMatch?.[1]) {
789
+ currentRound = Number.parseInt(roundMatch[1], 10);
790
+ }
791
+ return;
627
792
  }
628
- return this.fetchRoundMatchItems(round.providerId);
793
+ const cells = $(row).find("td.data");
794
+ if (cells.length < 5) return;
795
+ const dateText = $(cells[0]).text().trim();
796
+ const teamsCell = $(cells[1]);
797
+ const venue = $(cells[2]).text().trim();
798
+ const attendance = $(cells[3]).text().trim();
799
+ const scoreCell = $(cells[4]);
800
+ if (venue === "BYE") return;
801
+ const teamLinks = teamsCell.find("a");
802
+ if (teamLinks.length < 2) return;
803
+ const homeTeam = normaliseTeamName($(teamLinks[0]).text().trim());
804
+ const awayTeam = normaliseTeamName($(teamLinks[1]).text().trim());
805
+ const scoreText = scoreCell.text().trim();
806
+ const scoreMatch = /(\d+)-(\d+)/.exec(scoreText);
807
+ if (!scoreMatch) return;
808
+ const homePoints = Number.parseInt(scoreMatch[1] ?? "0", 10);
809
+ const awayPoints = Number.parseInt(scoreMatch[2] ?? "0", 10);
810
+ const scoreLink = scoreCell.find("a").attr("href") ?? "";
811
+ const midMatch = /mid=(\d+)/.exec(scoreLink);
812
+ const matchId = midMatch?.[1] ? `FW_${midMatch[1]}` : `FW_${year}_R${currentRound}_${homeTeam}`;
813
+ const date = parseFootyWireDate(dateText) ?? new Date(year, 0, 1);
814
+ const homeGoals = Math.floor(homePoints / 6);
815
+ const homeBehinds = homePoints - homeGoals * 6;
816
+ const awayGoals = Math.floor(awayPoints / 6);
817
+ const awayBehinds = awayPoints - awayGoals * 6;
818
+ results.push({
819
+ matchId,
820
+ season: year,
821
+ roundNumber: currentRound,
822
+ roundType: currentRoundType,
823
+ date,
824
+ venue,
825
+ homeTeam,
826
+ awayTeam,
827
+ homeGoals,
828
+ homeBehinds,
829
+ homePoints,
830
+ awayGoals,
831
+ awayBehinds,
832
+ awayPoints,
833
+ margin: homePoints - awayPoints,
834
+ q1Home: null,
835
+ q2Home: null,
836
+ q3Home: null,
837
+ q4Home: null,
838
+ q1Away: null,
839
+ q2Away: null,
840
+ q3Away: null,
841
+ q4Away: null,
842
+ status: "Complete",
843
+ attendance: attendance ? Number.parseInt(attendance, 10) || null : null,
844
+ venueState: null,
845
+ venueTimezone: null,
846
+ homeRushedBehinds: null,
847
+ awayRushedBehinds: null,
848
+ homeMinutesInFront: null,
849
+ awayMinutesInFront: null,
850
+ source: "footywire",
851
+ competition: "AFLM"
852
+ });
853
+ });
854
+ return results;
855
+ }
856
+ function parseFixtureList(html, year) {
857
+ const $ = cheerio2.load(html);
858
+ const fixtures = [];
859
+ let currentRound = 0;
860
+ let currentRoundType = "HomeAndAway";
861
+ let gameNumber = 0;
862
+ $("tr").each((_i, row) => {
863
+ const roundHeader = $(row).find("td[colspan='7']");
864
+ if (roundHeader.length > 0) {
865
+ const text = roundHeader.text().trim();
866
+ currentRoundType = inferRoundType(text);
867
+ const roundMatch = /Round\s+(\d+)/i.exec(text);
868
+ if (roundMatch?.[1]) {
869
+ currentRound = Number.parseInt(roundMatch[1], 10);
870
+ }
871
+ return;
872
+ }
873
+ const cells = $(row).find("td.data");
874
+ if (cells.length < 3) return;
875
+ const dateText = $(cells[0]).text().trim();
876
+ const teamsCell = $(cells[1]);
877
+ const venue = $(cells[2]).text().trim();
878
+ if (venue === "BYE") return;
879
+ const teamLinks = teamsCell.find("a");
880
+ if (teamLinks.length < 2) return;
881
+ const homeTeam = normaliseTeamName($(teamLinks[0]).text().trim());
882
+ const awayTeam = normaliseTeamName($(teamLinks[1]).text().trim());
883
+ const date = parseFootyWireDate(dateText) ?? new Date(year, 0, 1);
884
+ gameNumber++;
885
+ const scoreCell = cells.length >= 5 ? $(cells[4]) : null;
886
+ const scoreText = scoreCell?.text().trim() ?? "";
887
+ const hasScore = /\d+-\d+/.test(scoreText);
888
+ fixtures.push({
889
+ matchId: `FW_${year}_R${currentRound}_G${gameNumber}`,
890
+ season: year,
891
+ roundNumber: currentRound,
892
+ roundType: currentRoundType,
893
+ date,
894
+ venue,
895
+ homeTeam,
896
+ awayTeam,
897
+ status: hasScore ? "Complete" : "Upcoming",
898
+ competition: "AFLM"
899
+ });
900
+ });
901
+ return fixtures;
902
+ }
903
+ function parseFootyWireTeamStats(html, year, suffix) {
904
+ const $ = cheerio2.load(html);
905
+ const entries = [];
906
+ const tables = $("table");
907
+ const mainTable = tables.length > 10 ? $(tables[10]) : $("table.sortable").first();
908
+ if (mainTable.length === 0) return entries;
909
+ const STAT_KEYS = [
910
+ "K",
911
+ "HB",
912
+ "D",
913
+ "M",
914
+ "G",
915
+ "GA",
916
+ "I50",
917
+ "BH",
918
+ "T",
919
+ "HO",
920
+ "FF",
921
+ "FA",
922
+ "CL",
923
+ "CG",
924
+ "R50",
925
+ "AF",
926
+ "SC"
927
+ ];
928
+ const rows = mainTable.find("tr");
929
+ rows.each((rowIdx, row) => {
930
+ if (rowIdx === 0) return;
931
+ const cells = $(row).find("td");
932
+ if (cells.length < 20) return;
933
+ const teamLink = $(cells[1]).find("a");
934
+ const teamText = teamLink.length > 0 ? teamLink.text().trim() : $(cells[1]).text().trim();
935
+ const teamName = normaliseTeamName(teamText);
936
+ if (!teamName) return;
937
+ const parseNum = (cell) => Number.parseFloat(cell.text().trim()) || 0;
938
+ const gamesPlayed = parseNum($(cells[2]));
939
+ const stats = {};
940
+ for (let i = 0; i < STAT_KEYS.length; i++) {
941
+ const key = suffix === "against" ? `${STAT_KEYS[i]}_against` : STAT_KEYS[i];
942
+ stats[key] = parseNum($(cells[i + 3]));
943
+ }
944
+ entries.push({
945
+ season: year,
946
+ team: teamName,
947
+ gamesPlayed,
948
+ stats,
949
+ source: "footywire"
950
+ });
951
+ });
952
+ return entries;
953
+ }
954
+ function mergeTeamAndOppStats(teamStats, oppStats) {
955
+ const oppMap = /* @__PURE__ */ new Map();
956
+ for (const entry of oppStats) {
957
+ oppMap.set(entry.team, entry.stats);
958
+ }
959
+ return teamStats.map((entry) => {
960
+ const opp = oppMap.get(entry.team);
961
+ if (!opp) return entry;
962
+ return {
963
+ ...entry,
964
+ stats: { ...entry.stats, ...opp }
965
+ };
966
+ });
967
+ }
968
+ var FOOTYWIRE_SLUG_MAP = /* @__PURE__ */ new Map([
969
+ ["Adelaide Crows", "adelaide-crows"],
970
+ ["Brisbane Lions", "brisbane-lions"],
971
+ ["Carlton", "carlton-blues"],
972
+ ["Collingwood", "collingwood-magpies"],
973
+ ["Essendon", "essendon-bombers"],
974
+ ["Fremantle", "fremantle-dockers"],
975
+ ["Geelong Cats", "geelong-cats"],
976
+ ["Gold Coast Suns", "gold-coast-suns"],
977
+ ["GWS Giants", "greater-western-sydney-giants"],
978
+ ["Hawthorn", "hawthorn-hawks"],
979
+ ["Melbourne", "melbourne-demons"],
980
+ ["North Melbourne", "north-melbourne-kangaroos"],
981
+ ["Port Adelaide", "port-adelaide-power"],
982
+ ["Richmond", "richmond-tigers"],
983
+ ["St Kilda", "st-kilda-saints"],
984
+ ["Sydney Swans", "sydney-swans"],
985
+ ["West Coast Eagles", "west-coast-eagles"],
986
+ ["Western Bulldogs", "western-bulldogs"]
987
+ ]);
988
+ function teamNameToFootyWireSlug(teamName) {
989
+ return FOOTYWIRE_SLUG_MAP.get(teamName);
990
+ }
991
+ function parseFootyWirePlayerList(html, teamName) {
992
+ const $ = cheerio2.load(html);
993
+ const players = [];
994
+ let dataRows = null;
995
+ $("table").each((_i, table) => {
996
+ const firstRow = $(table).find("tr").first();
997
+ const cells = firstRow.find("td, th");
998
+ const cellTexts = cells.map((_j, c) => $(c).text().trim()).get();
999
+ if (cellTexts.includes("Age") && cellTexts.includes("Name")) {
1000
+ dataRows = $(table).find("tr");
1001
+ return false;
1002
+ }
1003
+ });
1004
+ if (!dataRows) return players;
1005
+ dataRows.each((_rowIdx, row) => {
1006
+ const cells = $(row).find("td");
1007
+ if (cells.length < 6) return;
1008
+ const jumperText = $(cells[0]).text().trim();
1009
+ const nameText = $(cells[1]).text().trim();
1010
+ const gamesText = $(cells[2]).text().trim();
1011
+ const dobText = cells.length > 4 ? $(cells[4]).text().trim() : "";
1012
+ const heightText = cells.length > 5 ? $(cells[5]).text().trim() : "";
1013
+ const origin = cells.length > 6 ? $(cells[6]).text().trim() : "";
1014
+ const position = cells.length > 7 ? $(cells[7]).text().trim() : "";
1015
+ const cleanedName = nameText.replace(/\nR$/, "").trim();
1016
+ if (!cleanedName || cleanedName === "Name") return;
1017
+ const nameParts = cleanedName.split(",").map((s) => s.trim());
1018
+ const surname = nameParts[0] ?? "";
1019
+ const givenName = nameParts[1] ?? "";
1020
+ const jumperNumber = jumperText ? Number.parseInt(jumperText, 10) || null : null;
1021
+ const gamesPlayed = gamesText ? Number.parseInt(gamesText, 10) || null : null;
1022
+ const heightMatch = /(\d+)cm/.exec(heightText);
1023
+ const heightCm = heightMatch?.[1] ? Number.parseInt(heightMatch[1], 10) || null : null;
1024
+ players.push({
1025
+ playerId: `FW_${teamName}_${surname}_${givenName}`.replace(/\s+/g, "_"),
1026
+ givenName,
1027
+ surname,
1028
+ displayName: givenName ? `${givenName} ${surname}` : surname,
1029
+ team: teamName,
1030
+ jumperNumber,
1031
+ position: position || null,
1032
+ dateOfBirth: dobText || null,
1033
+ heightCm,
1034
+ weightKg: null,
1035
+ gamesPlayed,
1036
+ goals: null,
1037
+ draftYear: null,
1038
+ draftPosition: null,
1039
+ draftType: null,
1040
+ debutYear: null,
1041
+ recruitedFrom: origin || null
1042
+ });
1043
+ });
1044
+ return players;
1045
+ }
1046
+
1047
+ // src/transforms/awards.ts
1048
+ import * as cheerio3 from "cheerio";
1049
+ function parseBrownlowVotes(html, season) {
1050
+ const $ = cheerio3.load(html);
1051
+ const results = [];
1052
+ $("table").each((_i, table) => {
1053
+ const rows = $(table).find("tr");
1054
+ if (rows.length < 5) return;
1055
+ const firstDataRow = $(rows[1]);
1056
+ const cells = firstDataRow.find("td");
1057
+ if (cells.length !== 9) return;
1058
+ const headerRow = $(rows[0]);
1059
+ const headerCells = headerRow.find("td, th");
1060
+ const firstHeader = headerCells.first().text().trim().toLowerCase();
1061
+ const startIdx = firstHeader === "player" || firstHeader === "" ? 1 : 0;
1062
+ rows.each((j, row) => {
1063
+ if (j < startIdx) return;
1064
+ const tds = $(row).find("td");
1065
+ if (tds.length < 9) return;
1066
+ const player = $(tds[0]).text().trim();
1067
+ const team = $(tds[1]).text().trim();
1068
+ if (!player || player.toLowerCase() === "player") return;
1069
+ const votes3 = safeInt($(tds[2]).text()) ?? 0;
1070
+ const votes2 = safeInt($(tds[3]).text()) ?? 0;
1071
+ const votes1 = safeInt($(tds[4]).text()) ?? 0;
1072
+ const gamesPolled = safeInt($(tds[6]).text());
1073
+ results.push({
1074
+ type: "brownlow",
1075
+ season,
1076
+ player,
1077
+ team,
1078
+ votes: votes3 * 3 + votes2 * 2 + votes1,
1079
+ votes3,
1080
+ votes2,
1081
+ votes1,
1082
+ gamesPolled
1083
+ });
1084
+ });
1085
+ });
1086
+ return results;
1087
+ }
1088
+ function parseAllAustralian(html, season) {
1089
+ const $ = cheerio3.load(html);
1090
+ const results = [];
1091
+ const rows = $("tr");
1092
+ rows.each((_i, row) => {
1093
+ const tds = $(row).find("td");
1094
+ if (tds.length < 2) return;
1095
+ const position = $(tds[0]).text().trim();
1096
+ if (!position) return;
1097
+ const validPositions = ["FB", "HB", "C", "HF", "FF", "FOL", "IC", "EMG"];
1098
+ if (!validPositions.includes(position)) return;
1099
+ tds.each((cellIdx, cell) => {
1100
+ if (cellIdx === 0) return;
1101
+ const playerLink = $(cell).find("a");
1102
+ const playerName = playerLink.text().trim();
1103
+ const teamSpan = $(cell).find("span.playerflag");
1104
+ const team = teamSpan.text().trim();
1105
+ if (playerName && team) {
1106
+ results.push({
1107
+ type: "all-australian",
1108
+ season,
1109
+ position,
1110
+ player: playerName,
1111
+ team
1112
+ });
1113
+ }
1114
+ });
1115
+ });
1116
+ return results;
1117
+ }
1118
+ function parseRisingStarNominations(html, season) {
1119
+ const $ = cheerio3.load(html);
1120
+ const results = [];
1121
+ const tables = $("table");
1122
+ let targetRows = null;
1123
+ tables.each((_i, table) => {
1124
+ const rows = $(table).find("tr");
1125
+ if (rows.length < 5) return;
1126
+ const firstRow = $(rows[0]);
1127
+ const headerCells = firstRow.find("td, th").map((_, c) => $(c).text().trim()).get();
1128
+ if (headerCells.length >= 15 && (headerCells[0]?.toLowerCase().includes("round") || headerCells[0]?.toLowerCase().includes("rnd"))) {
1129
+ targetRows = rows;
1130
+ return false;
1131
+ }
1132
+ });
1133
+ if (!targetRows) return results;
1134
+ targetRows.each((_j, row) => {
1135
+ if (_j === 0) return;
1136
+ const tds = $(row).find("td");
1137
+ if (tds.length < 15) return;
1138
+ const roundText = $(tds[0]).text().trim();
1139
+ const round = safeInt(roundText);
1140
+ if (round == null) return;
1141
+ const player = $(tds[1]).text().trim();
1142
+ const team = $(tds[2]).text().trim();
1143
+ const opponent = $(tds[3]).text().trim();
1144
+ if (!player || player.toLowerCase() === "name") return;
1145
+ results.push({
1146
+ type: "rising-star",
1147
+ season,
1148
+ round,
1149
+ player,
1150
+ team,
1151
+ opponent,
1152
+ kicks: safeInt($(tds[4]).text()),
1153
+ handballs: safeInt($(tds[5]).text()),
1154
+ disposals: safeInt($(tds[6]).text()),
1155
+ marks: safeInt($(tds[7]).text()),
1156
+ goals: safeInt($(tds[8]).text()),
1157
+ behinds: safeInt($(tds[9]).text()),
1158
+ tackles: safeInt($(tds[10]).text())
1159
+ });
1160
+ });
1161
+ return results;
1162
+ }
1163
+
1164
+ // src/api/awards.ts
1165
+ var FOOTYWIRE_BASE2 = "https://www.footywire.com/afl/footy";
1166
+ async function fetchAwards(query) {
1167
+ const client = new FootyWireClient();
1168
+ switch (query.award) {
1169
+ case "brownlow": {
1170
+ const url = `${FOOTYWIRE_BASE2}/brownlow_medal?year=${query.season}`;
1171
+ const htmlResult = await client.fetchPage(url);
1172
+ if (!htmlResult.success) return htmlResult;
1173
+ const votes = parseBrownlowVotes(htmlResult.data, query.season);
1174
+ if (votes.length === 0) {
1175
+ return err(
1176
+ new ScrapeError(`No Brownlow data found for season ${query.season}`, "footywire")
1177
+ );
1178
+ }
1179
+ return ok(votes);
1180
+ }
1181
+ case "all-australian": {
1182
+ const url = `${FOOTYWIRE_BASE2}/all_australian_selection?year=${query.season}`;
1183
+ const htmlResult = await client.fetchPage(url);
1184
+ if (!htmlResult.success) return htmlResult;
1185
+ const selections = parseAllAustralian(htmlResult.data, query.season);
1186
+ if (selections.length === 0) {
1187
+ return err(
1188
+ new ScrapeError(`No All-Australian data found for season ${query.season}`, "footywire")
1189
+ );
1190
+ }
1191
+ return ok(selections);
1192
+ }
1193
+ case "rising-star": {
1194
+ const url = `${FOOTYWIRE_BASE2}/rising_star_nominations?year=${query.season}`;
1195
+ const htmlResult = await client.fetchPage(url);
1196
+ if (!htmlResult.success) return htmlResult;
1197
+ const nominations = parseRisingStarNominations(htmlResult.data, query.season);
1198
+ if (nominations.length === 0) {
1199
+ return err(
1200
+ new ScrapeError(`No Rising Star data found for season ${query.season}`, "footywire")
1201
+ );
1202
+ }
1203
+ return ok(nominations);
1204
+ }
1205
+ default:
1206
+ return err(new ScrapeError(`Unknown award type: ${query.award}`, "footywire"));
1207
+ }
1208
+ }
1209
+
1210
+ // src/sources/afl-coaches.ts
1211
+ import * as cheerio4 from "cheerio";
1212
+ var AflCoachesClient = class {
1213
+ fetchFn;
1214
+ constructor(options) {
1215
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch;
629
1216
  }
630
1217
  /**
631
- * Fetch match items for all completed rounds in a season.
632
- *
633
- * @param seasonId - The compseason ID.
634
- * @returns Aggregated array of match items from all completed rounds.
1218
+ * Fetch the HTML content of an AFLCA page.
635
1219
  */
636
- async fetchSeasonMatchItems(seasonId) {
637
- const roundsResult = await this.resolveRounds(seasonId);
638
- if (!roundsResult.success) {
639
- return roundsResult;
640
- }
641
- const providerIds = roundsResult.data.flatMap((r) => r.providerId ? [r.providerId] : []);
642
- const results = await Promise.all(providerIds.map((id) => this.fetchRoundMatchItems(id)));
643
- const allItems = [];
644
- for (const result of results) {
645
- if (!result.success) {
646
- return result;
1220
+ async fetchHtml(url) {
1221
+ try {
1222
+ const response = await this.fetchFn(url, {
1223
+ headers: {
1224
+ "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"
1225
+ }
1226
+ });
1227
+ if (!response.ok) {
1228
+ return err(
1229
+ new ScrapeError(`AFL Coaches request failed: ${response.status} (${url})`, "afl-coaches")
1230
+ );
647
1231
  }
648
- const concluded = result.data.filter(
649
- (item) => item.match.status === "CONCLUDED" || item.match.status === "COMPLETE"
1232
+ const html = await response.text();
1233
+ return ok(html);
1234
+ } catch (cause) {
1235
+ return err(
1236
+ new ScrapeError(
1237
+ `AFL Coaches request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1238
+ "afl-coaches"
1239
+ )
650
1240
  );
651
- allItems.push(...concluded);
652
1241
  }
653
- return ok(allItems);
654
1242
  }
655
1243
  /**
656
- * Fetch per-player statistics for a match.
1244
+ * Build the AFLCA leaderboard URL for a given season, round, and competition.
657
1245
  *
658
- * @param matchProviderId - The match provider ID (e.g. "CD_M20250140101").
659
- * @returns Player stats list with home and away arrays.
1246
+ * Mirrors the R package URL construction from `helper-aflcoaches.R`.
1247
+ *
1248
+ * @param season - Season year (e.g. 2024).
1249
+ * @param roundNumber - Round number.
1250
+ * @param competition - "AFLM" or "AFLW".
1251
+ * @param isFinals - Whether this is a finals round.
660
1252
  */
661
- async fetchPlayerStats(matchProviderId) {
662
- return this.fetchJson(
663
- `${CFS_BASE}/playerStats/match/${matchProviderId}`,
664
- PlayerStatsListSchema
665
- );
1253
+ buildUrl(season, roundNumber, competition, isFinals) {
1254
+ 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/";
1255
+ const compSuffix = competition === "AFLW" ? "02" : "01";
1256
+ const secondPart = season >= 2023 ? season + 1 : season;
1257
+ const roundPad = String(roundNumber).padStart(2, "0");
1258
+ return `${linkBase}${season}/${secondPart}${compSuffix}${roundPad}`;
666
1259
  }
667
1260
  /**
668
- * Fetch match roster (lineup) for a match.
1261
+ * Scrape coaches votes for a single round.
669
1262
  *
670
- * @param matchProviderId - The match provider ID (e.g. "CD_M20250140101").
671
- * @returns Match roster with team players.
1263
+ * @param season - Season year.
1264
+ * @param roundNumber - Round number.
1265
+ * @param competition - "AFLM" or "AFLW".
1266
+ * @param isFinals - Whether this is a finals round.
1267
+ * @returns Array of coaches vote records for that round.
672
1268
  */
673
- async fetchMatchRoster(matchProviderId) {
674
- return this.fetchJson(`${CFS_BASE}/matchRoster/full/${matchProviderId}`, MatchRosterSchema);
1269
+ async scrapeRoundVotes(season, roundNumber, competition, isFinals) {
1270
+ const url = this.buildUrl(season, roundNumber, competition, isFinals);
1271
+ const htmlResult = await this.fetchHtml(url);
1272
+ if (!htmlResult.success) {
1273
+ return htmlResult;
1274
+ }
1275
+ try {
1276
+ const votes = parseCoachesVotesHtml(htmlResult.data, season, roundNumber);
1277
+ return ok(votes);
1278
+ } catch (cause) {
1279
+ return err(
1280
+ new ScrapeError(
1281
+ `Failed to parse coaches votes: ${cause instanceof Error ? cause.message : String(cause)}`,
1282
+ "afl-coaches"
1283
+ )
1284
+ );
1285
+ }
1286
+ }
1287
+ /**
1288
+ * Fetch coaches votes for an entire season (all rounds).
1289
+ *
1290
+ * Iterates over rounds 1-30, skipping rounds that return errors (e.g. byes or
1291
+ * rounds that haven't been played yet). Finals rounds (>= 24) use the finals URL.
1292
+ *
1293
+ * @param season - Season year.
1294
+ * @param competition - "AFLM" or "AFLW".
1295
+ * @returns Combined array of coaches votes for the season.
1296
+ */
1297
+ async fetchSeasonVotes(season, competition) {
1298
+ const allVotes = [];
1299
+ const maxRound = 30;
1300
+ for (let round = 1; round <= maxRound; round++) {
1301
+ const isFinals = round >= 24 && season >= 2018;
1302
+ const result = await this.scrapeRoundVotes(season, round, competition, isFinals);
1303
+ if (result.success && result.data.length > 0) {
1304
+ allVotes.push(...result.data);
1305
+ }
1306
+ }
1307
+ if (allVotes.length === 0) {
1308
+ return err(new ScrapeError(`No coaches votes found for season ${season}`, "afl-coaches"));
1309
+ }
1310
+ return ok(allVotes);
1311
+ }
1312
+ };
1313
+ function parseCoachesVotesHtml(html, season, roundNumber) {
1314
+ const $ = cheerio4.load(html);
1315
+ const clubLogos = $(".pr-md-3.votes-by-match .club_logo");
1316
+ const homeTeams = [];
1317
+ const awayTeams = [];
1318
+ clubLogos.each((i, el) => {
1319
+ const title = $(el).attr("title") ?? "";
1320
+ if (i % 2 === 0) {
1321
+ homeTeams.push(title);
1322
+ } else {
1323
+ awayTeams.push(title);
1324
+ }
1325
+ });
1326
+ const rawVotes = [];
1327
+ $(".pr-md-3.votes-by-match .col-2").each((_i, el) => {
1328
+ const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
1329
+ rawVotes.push(text);
1330
+ });
1331
+ const rawPlayers = [];
1332
+ $(".pr-md-3.votes-by-match .col-10").each((_i, el) => {
1333
+ const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
1334
+ rawPlayers.push(text);
1335
+ });
1336
+ const votes = [];
1337
+ let matchIndex = 0;
1338
+ for (let i = 0; i < rawPlayers.length; i++) {
1339
+ const playerName = rawPlayers[i] ?? "";
1340
+ const voteText = rawVotes[i] ?? "";
1341
+ if (playerName === "Player (Club)" && voteText === "Votes") {
1342
+ matchIndex++;
1343
+ continue;
1344
+ }
1345
+ const homeTeam = homeTeams[matchIndex - 1];
1346
+ const awayTeam = awayTeams[matchIndex - 1];
1347
+ if (homeTeam == null || awayTeam == null) {
1348
+ continue;
1349
+ }
1350
+ const voteCount = Number(voteText);
1351
+ if (Number.isNaN(voteCount)) {
1352
+ continue;
1353
+ }
1354
+ votes.push({
1355
+ season,
1356
+ round: roundNumber,
1357
+ homeTeam,
1358
+ awayTeam,
1359
+ playerName,
1360
+ votes: voteCount
1361
+ });
1362
+ }
1363
+ return votes;
1364
+ }
1365
+
1366
+ // src/api/coaches-votes.ts
1367
+ async function fetchCoachesVotes(query) {
1368
+ const competition = query.competition ?? "AFLM";
1369
+ if (query.season < 2006) {
1370
+ return err(new ScrapeError("No coaches votes data available before 2006", "afl-coaches"));
1371
+ }
1372
+ if (competition === "AFLW" && query.season < 2018) {
1373
+ return err(new ScrapeError("No AFLW coaches votes data available before 2018", "afl-coaches"));
1374
+ }
1375
+ const client = new AflCoachesClient();
1376
+ let result;
1377
+ if (query.round != null) {
1378
+ const isFinals = query.round >= 24 && query.season >= 2018;
1379
+ result = await client.scrapeRoundVotes(query.season, query.round, competition, isFinals);
1380
+ } else {
1381
+ result = await client.fetchSeasonVotes(query.season, competition);
1382
+ }
1383
+ if (!result.success) {
1384
+ return result;
1385
+ }
1386
+ let votes = result.data;
1387
+ if (query.team != null) {
1388
+ const normalisedTeam = normaliseTeamName(query.team);
1389
+ votes = votes.filter(
1390
+ (v) => normaliseTeamName(v.homeTeam) === normalisedTeam || normaliseTeamName(v.awayTeam) === normalisedTeam
1391
+ );
1392
+ if (votes.length === 0) {
1393
+ return ok([]);
1394
+ }
1395
+ }
1396
+ return ok(votes);
1397
+ }
1398
+
1399
+ // src/lib/validation.ts
1400
+ import { z } from "zod/v4";
1401
+ var AflApiTokenSchema = z.object({
1402
+ token: z.string(),
1403
+ disclaimer: z.string().optional()
1404
+ }).passthrough();
1405
+ var CompetitionSchema = z.object({
1406
+ id: z.number(),
1407
+ name: z.string(),
1408
+ code: z.string().optional()
1409
+ }).passthrough();
1410
+ var CompetitionListSchema = z.object({
1411
+ competitions: z.array(CompetitionSchema)
1412
+ }).passthrough();
1413
+ var CompseasonSchema = z.object({
1414
+ id: z.number(),
1415
+ name: z.string(),
1416
+ shortName: z.string().optional(),
1417
+ currentRoundNumber: z.number().optional()
1418
+ }).passthrough();
1419
+ var CompseasonListSchema = z.object({
1420
+ compSeasons: z.array(CompseasonSchema)
1421
+ }).passthrough();
1422
+ var RoundSchema = z.object({
1423
+ id: z.number(),
1424
+ /** Provider ID used by /cfs/ endpoints (e.g. "CD_R202501401"). */
1425
+ providerId: z.string().optional(),
1426
+ name: z.string(),
1427
+ abbreviation: z.string().optional(),
1428
+ roundNumber: z.number(),
1429
+ utcStartTime: z.string().optional(),
1430
+ utcEndTime: z.string().optional()
1431
+ }).passthrough();
1432
+ var RoundListSchema = z.object({
1433
+ rounds: z.array(RoundSchema)
1434
+ }).passthrough();
1435
+ var ScoreSchema = z.object({
1436
+ totalScore: z.number(),
1437
+ goals: z.number(),
1438
+ behinds: z.number(),
1439
+ superGoals: z.number().nullable().optional()
1440
+ }).passthrough();
1441
+ var PeriodScoreSchema = z.object({
1442
+ periodNumber: z.number(),
1443
+ score: ScoreSchema
1444
+ }).passthrough();
1445
+ var TeamScoreSchema = z.object({
1446
+ matchScore: ScoreSchema,
1447
+ periodScore: z.array(PeriodScoreSchema).optional(),
1448
+ rushedBehinds: z.number().optional(),
1449
+ minutesInFront: z.number().optional()
1450
+ }).passthrough();
1451
+ var CfsMatchTeamSchema = z.object({
1452
+ name: z.string(),
1453
+ teamId: z.string(),
1454
+ abbr: z.string().optional(),
1455
+ nickname: z.string().optional()
1456
+ }).passthrough();
1457
+ var CfsMatchSchema = z.object({
1458
+ matchId: z.string(),
1459
+ name: z.string().optional(),
1460
+ status: z.string(),
1461
+ utcStartTime: z.string(),
1462
+ homeTeamId: z.string(),
1463
+ awayTeamId: z.string(),
1464
+ homeTeam: CfsMatchTeamSchema,
1465
+ awayTeam: CfsMatchTeamSchema,
1466
+ round: z.string().optional(),
1467
+ abbr: z.string().optional()
1468
+ }).passthrough();
1469
+ var CfsScoreSchema = z.object({
1470
+ status: z.string(),
1471
+ matchId: z.string(),
1472
+ homeTeamScore: TeamScoreSchema,
1473
+ awayTeamScore: TeamScoreSchema
1474
+ }).passthrough();
1475
+ var CfsVenueSchema = z.object({
1476
+ name: z.string(),
1477
+ venueId: z.string().optional(),
1478
+ state: z.string().optional(),
1479
+ timeZone: z.string().optional()
1480
+ }).passthrough();
1481
+ var MatchItemSchema = z.object({
1482
+ match: CfsMatchSchema,
1483
+ score: CfsScoreSchema.nullish(),
1484
+ venue: CfsVenueSchema.optional(),
1485
+ round: z.object({
1486
+ name: z.string(),
1487
+ roundId: z.string(),
1488
+ roundNumber: z.number()
1489
+ }).passthrough().optional()
1490
+ }).passthrough();
1491
+ var MatchItemListSchema = z.object({
1492
+ roundId: z.string().optional(),
1493
+ items: z.array(MatchItemSchema)
1494
+ }).passthrough();
1495
+ var CfsPlayerInnerSchema = z.object({
1496
+ playerId: z.string(),
1497
+ playerName: z.object({
1498
+ givenName: z.string(),
1499
+ surname: z.string()
1500
+ }).passthrough(),
1501
+ captain: z.boolean().optional(),
1502
+ playerJumperNumber: z.number().optional()
1503
+ }).passthrough();
1504
+ var PlayerGameStatsSchema = z.object({
1505
+ goals: z.number().optional(),
1506
+ behinds: z.number().optional(),
1507
+ kicks: z.number().optional(),
1508
+ handballs: z.number().optional(),
1509
+ disposals: z.number().optional(),
1510
+ marks: z.number().optional(),
1511
+ bounces: z.number().optional(),
1512
+ tackles: z.number().optional(),
1513
+ contestedPossessions: z.number().optional(),
1514
+ uncontestedPossessions: z.number().optional(),
1515
+ totalPossessions: z.number().optional(),
1516
+ inside50s: z.number().optional(),
1517
+ marksInside50: z.number().optional(),
1518
+ contestedMarks: z.number().optional(),
1519
+ hitouts: z.number().optional(),
1520
+ onePercenters: z.number().optional(),
1521
+ disposalEfficiency: z.number().optional(),
1522
+ clangers: z.number().optional(),
1523
+ freesFor: z.number().optional(),
1524
+ freesAgainst: z.number().optional(),
1525
+ dreamTeamPoints: z.number().optional(),
1526
+ clearances: z.object({
1527
+ centreClearances: z.number().optional(),
1528
+ stoppageClearances: z.number().optional(),
1529
+ totalClearances: z.number().optional()
1530
+ }).passthrough().optional(),
1531
+ rebound50s: z.number().optional(),
1532
+ goalAssists: z.number().optional(),
1533
+ goalAccuracy: z.number().optional(),
1534
+ turnovers: z.number().optional(),
1535
+ intercepts: z.number().optional(),
1536
+ tacklesInside50: z.number().optional(),
1537
+ shotsAtGoal: z.number().optional(),
1538
+ metresGained: z.number().optional(),
1539
+ scoreInvolvements: z.number().optional(),
1540
+ ratingPoints: z.number().optional(),
1541
+ extendedStats: z.object({
1542
+ effectiveDisposals: z.number().optional(),
1543
+ effectiveKicks: z.number().optional(),
1544
+ kickEfficiency: z.number().optional(),
1545
+ kickToHandballRatio: z.number().optional(),
1546
+ pressureActs: z.number().optional(),
1547
+ defHalfPressureActs: z.number().optional(),
1548
+ spoils: z.number().optional(),
1549
+ hitoutsToAdvantage: z.number().optional(),
1550
+ hitoutWinPercentage: z.number().optional(),
1551
+ hitoutToAdvantageRate: z.number().optional(),
1552
+ groundBallGets: z.number().optional(),
1553
+ f50GroundBallGets: z.number().optional(),
1554
+ interceptMarks: z.number().optional(),
1555
+ marksOnLead: z.number().optional(),
1556
+ contestedPossessionRate: z.number().optional(),
1557
+ contestOffOneOnOnes: z.number().optional(),
1558
+ contestOffWins: z.number().optional(),
1559
+ contestOffWinsPercentage: z.number().optional(),
1560
+ contestDefOneOnOnes: z.number().optional(),
1561
+ contestDefLosses: z.number().optional(),
1562
+ contestDefLossPercentage: z.number().optional(),
1563
+ centreBounceAttendances: z.number().optional(),
1564
+ kickins: z.number().optional(),
1565
+ kickinsPlayon: z.number().optional(),
1566
+ ruckContests: z.number().optional(),
1567
+ scoreLaunches: z.number().optional()
1568
+ }).passthrough().optional()
1569
+ }).passthrough();
1570
+ var PlayerStatsItemSchema = z.object({
1571
+ player: z.object({
1572
+ player: z.object({
1573
+ position: z.string().optional(),
1574
+ player: CfsPlayerInnerSchema
1575
+ }).passthrough(),
1576
+ jumperNumber: z.number().optional()
1577
+ }).passthrough(),
1578
+ teamId: z.string(),
1579
+ playerStats: z.object({
1580
+ stats: PlayerGameStatsSchema,
1581
+ timeOnGroundPercentage: z.number().optional()
1582
+ }).passthrough()
1583
+ }).passthrough();
1584
+ var PlayerStatsListSchema = z.object({
1585
+ homeTeamPlayerStats: z.array(PlayerStatsItemSchema),
1586
+ awayTeamPlayerStats: z.array(PlayerStatsItemSchema)
1587
+ }).passthrough();
1588
+ var RosterPlayerSchema = z.object({
1589
+ player: z.object({
1590
+ position: z.string().optional(),
1591
+ player: CfsPlayerInnerSchema
1592
+ }).passthrough(),
1593
+ jumperNumber: z.number().optional()
1594
+ }).passthrough();
1595
+ var TeamPlayersSchema = z.object({
1596
+ teamId: z.string(),
1597
+ players: z.array(RosterPlayerSchema)
1598
+ }).passthrough();
1599
+ var MatchRosterSchema = z.object({
1600
+ match: CfsMatchSchema,
1601
+ teamPlayers: z.array(TeamPlayersSchema)
1602
+ }).passthrough();
1603
+ var TeamItemSchema = z.object({
1604
+ id: z.number(),
1605
+ name: z.string(),
1606
+ abbreviation: z.string().optional(),
1607
+ teamType: z.string().optional()
1608
+ }).passthrough();
1609
+ var TeamListSchema = z.object({
1610
+ teams: z.array(TeamItemSchema)
1611
+ }).passthrough();
1612
+ var SquadPlayerInnerSchema = z.object({
1613
+ id: z.number(),
1614
+ providerId: z.string().optional(),
1615
+ firstName: z.string(),
1616
+ surname: z.string(),
1617
+ dateOfBirth: z.string().optional(),
1618
+ heightInCm: z.number().optional(),
1619
+ weightInKg: z.number().optional(),
1620
+ draftYear: z.string().optional(),
1621
+ draftPosition: z.string().optional(),
1622
+ draftType: z.string().optional(),
1623
+ debutYear: z.string().optional(),
1624
+ recruitedFrom: z.string().optional()
1625
+ }).passthrough();
1626
+ var SquadPlayerItemSchema = z.object({
1627
+ player: SquadPlayerInnerSchema,
1628
+ jumperNumber: z.number().optional(),
1629
+ position: z.string().optional()
1630
+ }).passthrough();
1631
+ var SquadSchema = z.object({
1632
+ team: z.object({
1633
+ name: z.string()
1634
+ }).passthrough().optional(),
1635
+ players: z.array(SquadPlayerItemSchema)
1636
+ }).passthrough();
1637
+ var SquadListSchema = z.object({
1638
+ squad: SquadSchema
1639
+ }).passthrough();
1640
+ var WinLossRecordSchema = z.object({
1641
+ wins: z.number(),
1642
+ losses: z.number(),
1643
+ draws: z.number(),
1644
+ played: z.number().optional()
1645
+ }).passthrough();
1646
+ var LadderEntryRawSchema = z.object({
1647
+ position: z.number(),
1648
+ team: z.object({
1649
+ name: z.string(),
1650
+ id: z.number().optional(),
1651
+ abbreviation: z.string().optional()
1652
+ }).passthrough(),
1653
+ played: z.number().optional(),
1654
+ pointsFor: z.number().optional(),
1655
+ pointsAgainst: z.number().optional(),
1656
+ thisSeasonRecord: z.object({
1657
+ aggregatePoints: z.number().optional(),
1658
+ percentage: z.number().optional(),
1659
+ winLossRecord: WinLossRecordSchema.optional()
1660
+ }).passthrough().optional(),
1661
+ form: z.string().optional()
1662
+ }).passthrough();
1663
+ var LadderResponseSchema = z.object({
1664
+ ladders: z.array(
1665
+ z.object({
1666
+ entries: z.array(LadderEntryRawSchema)
1667
+ }).passthrough()
1668
+ ),
1669
+ round: z.object({
1670
+ roundNumber: z.number(),
1671
+ name: z.string().optional()
1672
+ }).passthrough().optional()
1673
+ }).passthrough();
1674
+
1675
+ // src/sources/afl-api.ts
1676
+ var TOKEN_URL = "https://api.afl.com.au/cfs/afl/WMCTok";
1677
+ var API_BASE = "https://aflapi.afl.com.au/afl/v2";
1678
+ var CFS_BASE = "https://api.afl.com.au/cfs/afl";
1679
+ var AflApiClient = class {
1680
+ fetchFn;
1681
+ tokenUrl;
1682
+ cachedToken = null;
1683
+ constructor(options) {
1684
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch;
1685
+ this.tokenUrl = options?.tokenUrl ?? TOKEN_URL;
675
1686
  }
676
1687
  /**
677
- * Fetch team list, optionally filtered by team type.
1688
+ * Authenticate with the WMCTok token endpoint and cache the token.
678
1689
  *
679
- * @param teamType - Optional filter (e.g. "MEN", "WOMEN").
680
- * @returns Array of team items.
1690
+ * @returns The access token on success, or an error Result.
681
1691
  */
682
- async fetchTeams(teamType) {
683
- const result = await this.fetchJson(`${API_BASE}/teams?pageSize=100`, TeamListSchema);
684
- if (!result.success) {
685
- return result;
686
- }
687
- if (teamType) {
688
- return ok(result.data.teams.filter((t) => t.teamType === teamType));
1692
+ async authenticate() {
1693
+ try {
1694
+ const response = await this.fetchFn(this.tokenUrl, {
1695
+ method: "POST",
1696
+ headers: { "Content-Length": "0" }
1697
+ });
1698
+ if (!response.ok) {
1699
+ return err(new AflApiError(`Token request failed: ${response.status}`, response.status));
1700
+ }
1701
+ const json = await response.json();
1702
+ const parsed = AflApiTokenSchema.safeParse(json);
1703
+ if (!parsed.success) {
1704
+ return err(new AflApiError("Invalid token response format"));
1705
+ }
1706
+ const ttlMs = 30 * 60 * 1e3;
1707
+ this.cachedToken = {
1708
+ accessToken: parsed.data.token,
1709
+ expiresAt: Date.now() + ttlMs
1710
+ };
1711
+ return ok(parsed.data.token);
1712
+ } catch (cause) {
1713
+ return err(
1714
+ new AflApiError(
1715
+ `Token request failed: ${cause instanceof Error ? cause.message : String(cause)}`
1716
+ )
1717
+ );
689
1718
  }
690
- return ok(result.data.teams);
691
1719
  }
692
1720
  /**
693
- * Fetch squad (roster) for a team in a specific season.
694
- *
695
- * @param teamId - The numeric team ID.
696
- * @param compSeasonId - The compseason ID.
697
- * @returns Squad list response.
1721
+ * Whether the cached token is still valid (not expired).
698
1722
  */
699
- async fetchSquad(teamId, compSeasonId) {
700
- return this.fetchJson(
701
- `${API_BASE}/squads?teamId=${teamId}&compSeasonId=${compSeasonId}`,
702
- SquadListSchema
703
- );
1723
+ get isAuthenticated() {
1724
+ return this.cachedToken !== null && Date.now() < this.cachedToken.expiresAt;
704
1725
  }
705
1726
  /**
706
- * Fetch ladder standings for a season (optionally for a specific round).
1727
+ * Perform an authenticated fetch, automatically adding the bearer token.
1728
+ * Retries once on 401 by re-authenticating.
707
1729
  *
708
- * @param seasonId - The compseason ID.
709
- * @param roundId - Optional round ID (numeric `id`, not `providerId`).
710
- * @returns Ladder response with entries.
1730
+ * @param url - The URL to fetch.
1731
+ * @param init - Additional fetch options.
1732
+ * @returns The Response on success, or an error Result.
711
1733
  */
712
- async fetchLadder(seasonId, roundId) {
713
- let url = `${API_BASE}/compseasons/${seasonId}/ladders`;
714
- if (roundId != null) {
715
- url += `?roundId=${roundId}`;
1734
+ async authedFetch(url, init) {
1735
+ if (!this.isAuthenticated) {
1736
+ const authResult = await this.authenticate();
1737
+ if (!authResult.success) {
1738
+ return authResult;
1739
+ }
716
1740
  }
717
- return this.fetchJson(url, LadderResponseSchema);
718
- }
719
- };
720
-
721
- // src/transforms/match-results.ts
722
- var FINALS_PATTERN = /final|elimination|qualifying|preliminary|semi|grand/i;
723
- function inferRoundType(roundName) {
724
- return FINALS_PATTERN.test(roundName) ? "Finals" : "HomeAndAway";
725
- }
726
- function toMatchStatus(raw) {
727
- switch (raw) {
728
- case "CONCLUDED":
729
- case "COMPLETE":
730
- return "Complete";
731
- case "LIVE":
732
- case "IN_PROGRESS":
733
- return "Live";
734
- case "UPCOMING":
735
- case "SCHEDULED":
736
- return "Upcoming";
737
- case "POSTPONED":
738
- return "Postponed";
739
- case "CANCELLED":
740
- return "Cancelled";
741
- default:
742
- return "Complete";
743
- }
744
- }
745
- function toQuarterScore(period) {
746
- return {
747
- goals: period.score.goals,
748
- behinds: period.score.behinds,
749
- points: period.score.totalScore
750
- };
751
- }
752
- function findPeriod(periods, quarter) {
753
- if (!periods) return null;
754
- const period = periods.find((p) => p.periodNumber === quarter);
755
- return period ? toQuarterScore(period) : null;
756
- }
757
- function transformMatchItems(items, season, competition, source = "afl-api") {
758
- return items.map((item) => {
759
- const homeScore = item.score?.homeTeamScore;
760
- const awayScore = item.score?.awayTeamScore;
761
- const homePoints = homeScore?.matchScore.totalScore ?? 0;
762
- const awayPoints = awayScore?.matchScore.totalScore ?? 0;
763
- return {
764
- matchId: item.match.matchId,
765
- season,
766
- roundNumber: item.round?.roundNumber ?? 0,
767
- roundType: inferRoundType(item.round?.name ?? ""),
768
- date: new Date(item.match.utcStartTime),
769
- venue: item.venue?.name ?? "",
770
- homeTeam: normaliseTeamName(item.match.homeTeam.name),
771
- awayTeam: normaliseTeamName(item.match.awayTeam.name),
772
- homeGoals: homeScore?.matchScore.goals ?? 0,
773
- homeBehinds: homeScore?.matchScore.behinds ?? 0,
774
- homePoints,
775
- awayGoals: awayScore?.matchScore.goals ?? 0,
776
- awayBehinds: awayScore?.matchScore.behinds ?? 0,
777
- awayPoints,
778
- margin: homePoints - awayPoints,
779
- q1Home: findPeriod(homeScore?.periodScore, 1),
780
- q2Home: findPeriod(homeScore?.periodScore, 2),
781
- q3Home: findPeriod(homeScore?.periodScore, 3),
782
- q4Home: findPeriod(homeScore?.periodScore, 4),
783
- q1Away: findPeriod(awayScore?.periodScore, 1),
784
- q2Away: findPeriod(awayScore?.periodScore, 2),
785
- q3Away: findPeriod(awayScore?.periodScore, 3),
786
- q4Away: findPeriod(awayScore?.periodScore, 4),
787
- status: toMatchStatus(item.match.status),
788
- attendance: null,
789
- venueState: item.venue?.state ?? null,
790
- venueTimezone: item.venue?.timeZone ?? null,
791
- homeRushedBehinds: homeScore?.rushedBehinds ?? null,
792
- awayRushedBehinds: awayScore?.rushedBehinds ?? null,
793
- homeMinutesInFront: homeScore?.minutesInFront ?? null,
794
- awayMinutesInFront: awayScore?.minutesInFront ?? null,
795
- source,
796
- competition
1741
+ const doFetch = async () => {
1742
+ const token = this.cachedToken;
1743
+ if (!token) {
1744
+ throw new AflApiError("No cached token available");
1745
+ }
1746
+ const headers = new Headers(init?.headers);
1747
+ headers.set("x-media-mis-token", token.accessToken);
1748
+ return this.fetchFn(url, { ...init, headers });
797
1749
  };
798
- });
799
- }
800
-
801
- // src/api/fixture.ts
802
- function toFixture(item, season, fallbackRoundNumber, competition) {
803
- return {
804
- matchId: item.match.matchId,
805
- season,
806
- roundNumber: item.round?.roundNumber ?? fallbackRoundNumber,
807
- roundType: inferRoundType(item.round?.name ?? ""),
808
- date: new Date(item.match.utcStartTime),
809
- venue: item.venue?.name ?? "",
810
- homeTeam: normaliseTeamName(item.match.homeTeam.name),
811
- awayTeam: normaliseTeamName(item.match.awayTeam.name),
812
- status: toMatchStatus(item.match.status),
813
- competition
814
- };
815
- }
816
- async function fetchFixture(query) {
817
- const competition = query.competition ?? "AFLM";
818
- if (query.source !== "afl-api") {
819
- return err(
820
- new UnsupportedSourceError(
821
- "Fixture data is only available from the AFL API source.",
822
- query.source
823
- )
824
- );
825
- }
826
- const client = new AflApiClient();
827
- const seasonResult = await client.resolveCompSeason(competition, query.season);
828
- if (!seasonResult.success) return seasonResult;
829
- if (query.round != null) {
830
- const itemsResult = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
831
- if (!itemsResult.success) return itemsResult;
832
- return ok(itemsResult.data.map((item) => toFixture(item, query.season, 0, competition)));
1750
+ try {
1751
+ let response = await doFetch();
1752
+ if (response.status === 401) {
1753
+ const authResult = await this.authenticate();
1754
+ if (!authResult.success) {
1755
+ return authResult;
1756
+ }
1757
+ response = await doFetch();
1758
+ }
1759
+ if (!response.ok) {
1760
+ return err(
1761
+ new AflApiError(
1762
+ `Request failed: ${response.status} ${response.statusText}`,
1763
+ response.status
1764
+ )
1765
+ );
1766
+ }
1767
+ return ok(response);
1768
+ } catch (cause) {
1769
+ return err(
1770
+ new AflApiError(
1771
+ `Request failed: ${cause instanceof Error ? cause.message : String(cause)}`
1772
+ )
1773
+ );
1774
+ }
833
1775
  }
834
- const roundsResult = await client.resolveRounds(seasonResult.data);
835
- if (!roundsResult.success) return roundsResult;
836
- const roundProviderIds = roundsResult.data.flatMap(
837
- (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
838
- );
839
- const roundResults = await Promise.all(
840
- roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
841
- );
842
- const fixtures = [];
843
- for (let i = 0; i < roundResults.length; i++) {
844
- const result = roundResults[i];
845
- if (!result?.success) continue;
846
- const roundNumber = roundProviderIds[i]?.roundNumber ?? 0;
847
- for (const item of result.data) {
848
- fixtures.push(toFixture(item, query.season, roundNumber, competition));
1776
+ /**
1777
+ * Fetch JSON from a URL, validate with a Zod schema, and return a typed Result.
1778
+ *
1779
+ * @param url - The URL to fetch.
1780
+ * @param schema - Zod schema to validate the response against.
1781
+ * @returns Validated data on success, or an error Result.
1782
+ */
1783
+ async fetchJson(url, schema) {
1784
+ const isPublic = url.startsWith(API_BASE);
1785
+ let response;
1786
+ if (isPublic) {
1787
+ try {
1788
+ response = await this.fetchFn(url);
1789
+ if (!response.ok) {
1790
+ return err(
1791
+ new AflApiError(
1792
+ `Request failed: ${response.status} ${response.statusText}`,
1793
+ response.status
1794
+ )
1795
+ );
1796
+ }
1797
+ } catch (cause) {
1798
+ return err(
1799
+ new AflApiError(
1800
+ `Request failed: ${cause instanceof Error ? cause.message : String(cause)}`
1801
+ )
1802
+ );
1803
+ }
1804
+ } else {
1805
+ const fetchResult = await this.authedFetch(url);
1806
+ if (!fetchResult.success) {
1807
+ return fetchResult;
1808
+ }
1809
+ response = fetchResult.data;
1810
+ }
1811
+ try {
1812
+ const json = await response.json();
1813
+ const parsed = schema.safeParse(json);
1814
+ if (!parsed.success) {
1815
+ return err(
1816
+ new ValidationError("Response validation failed", [
1817
+ { path: url, message: String(parsed.error) }
1818
+ ])
1819
+ );
1820
+ }
1821
+ return ok(parsed.data);
1822
+ } catch (cause) {
1823
+ return err(
1824
+ new AflApiError(
1825
+ `JSON parse failed: ${cause instanceof Error ? cause.message : String(cause)}`
1826
+ )
1827
+ );
849
1828
  }
850
1829
  }
851
- return ok(fixtures);
852
- }
853
-
854
- // src/transforms/ladder.ts
855
- function transformLadderEntries(entries) {
856
- return entries.map((entry) => {
857
- const record = entry.thisSeasonRecord;
858
- const wl = record?.winLossRecord;
859
- return {
860
- position: entry.position,
861
- team: normaliseTeamName(entry.team.name),
862
- played: entry.played ?? wl?.played ?? 0,
863
- wins: wl?.wins ?? 0,
864
- losses: wl?.losses ?? 0,
865
- draws: wl?.draws ?? 0,
866
- pointsFor: entry.pointsFor ?? 0,
867
- pointsAgainst: entry.pointsAgainst ?? 0,
868
- percentage: record?.percentage ?? 0,
869
- premiershipsPoints: record?.aggregatePoints ?? 0,
870
- form: entry.form ?? null
871
- };
872
- });
873
- }
874
-
875
- // src/api/ladder.ts
876
- async function fetchLadder(query) {
877
- const competition = query.competition ?? "AFLM";
878
- if (query.source !== "afl-api") {
879
- return err(
880
- new UnsupportedSourceError(
881
- "Ladder data is only available from the AFL API source.",
882
- query.source
883
- )
1830
+ /**
1831
+ * Resolve a competition code (e.g. "AFLM") to its API competition ID.
1832
+ *
1833
+ * @param code - The competition code to resolve.
1834
+ * @returns The competition ID string on success.
1835
+ */
1836
+ async resolveCompetitionId(code) {
1837
+ const result = await this.fetchJson(
1838
+ `${API_BASE}/competitions?pageSize=50`,
1839
+ CompetitionListSchema
884
1840
  );
885
- }
886
- const client = new AflApiClient();
887
- const seasonResult = await client.resolveCompSeason(competition, query.season);
888
- if (!seasonResult.success) return seasonResult;
889
- let roundId;
890
- if (query.round != null) {
891
- const roundsResult = await client.resolveRounds(seasonResult.data);
892
- if (!roundsResult.success) return roundsResult;
893
- const round = roundsResult.data.find((r) => r.roundNumber === query.round);
894
- if (round) {
895
- roundId = round.id;
1841
+ if (!result.success) {
1842
+ return result;
1843
+ }
1844
+ const apiCode = code === "AFLM" ? "AFL" : code;
1845
+ const competition = result.data.competitions.find((c) => c.code === apiCode);
1846
+ if (!competition) {
1847
+ return err(new AflApiError(`Competition not found for code: ${code}`));
896
1848
  }
1849
+ return ok(competition.id);
897
1850
  }
898
- const ladderResult = await client.fetchLadder(seasonResult.data, roundId);
899
- if (!ladderResult.success) return ladderResult;
900
- const firstLadder = ladderResult.data.ladders[0];
901
- const entries = firstLadder ? transformLadderEntries(firstLadder.entries) : [];
902
- return ok({
903
- season: query.season,
904
- roundNumber: ladderResult.data.round?.roundNumber ?? null,
905
- entries,
906
- competition
907
- });
908
- }
909
-
910
- // src/transforms/lineup.ts
911
- var EMERGENCY_POSITIONS = /* @__PURE__ */ new Set(["EMG", "EMERG"]);
912
- var SUBSTITUTE_POSITIONS = /* @__PURE__ */ new Set(["SUB", "INT"]);
913
- function transformMatchRoster(roster, season, roundNumber, competition) {
914
- const homeTeamId = roster.match.homeTeamId;
915
- const awayTeamId = roster.match.awayTeamId;
916
- const homeTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === homeTeamId);
917
- const awayTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === awayTeamId);
918
- const mapPlayers = (players) => players.map((p) => {
919
- const inner = p.player.player;
920
- const position = p.player.position ?? null;
921
- return {
922
- playerId: inner.playerId,
923
- givenName: inner.playerName.givenName,
924
- surname: inner.playerName.surname,
925
- displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
926
- jumperNumber: p.jumperNumber ?? null,
927
- position,
928
- isEmergency: position !== null && EMERGENCY_POSITIONS.has(position),
929
- isSubstitute: position !== null && SUBSTITUTE_POSITIONS.has(position)
930
- };
931
- });
932
- return {
933
- matchId: roster.match.matchId,
934
- season,
935
- roundNumber,
936
- homeTeam: normaliseTeamName(roster.match.homeTeam.name),
937
- awayTeam: normaliseTeamName(roster.match.awayTeam.name),
938
- homePlayers: homeTeamPlayers ? mapPlayers(homeTeamPlayers.players) : [],
939
- awayPlayers: awayTeamPlayers ? mapPlayers(awayTeamPlayers.players) : [],
940
- competition
941
- };
942
- }
943
-
944
- // src/api/lineup.ts
945
- async function fetchLineup(query) {
946
- const competition = query.competition ?? "AFLM";
947
- if (query.source !== "afl-api") {
948
- return err(
949
- new UnsupportedSourceError(
950
- "Lineup data is only available from the AFL API source.",
951
- query.source
952
- )
1851
+ /**
1852
+ * Resolve a season (compseason) ID from a competition ID and year.
1853
+ *
1854
+ * @param competitionId - The competition ID (from {@link resolveCompetitionId}).
1855
+ * @param year - The season year (e.g. 2024).
1856
+ * @returns The compseason ID string on success.
1857
+ */
1858
+ async resolveSeasonId(competitionId, year) {
1859
+ const result = await this.fetchJson(
1860
+ `${API_BASE}/competitions/${competitionId}/compseasons?pageSize=100`,
1861
+ CompseasonListSchema
953
1862
  );
1863
+ if (!result.success) {
1864
+ return result;
1865
+ }
1866
+ const yearStr = String(year);
1867
+ const season = result.data.compSeasons.find((cs) => cs.name.includes(yearStr));
1868
+ if (!season) {
1869
+ return err(new AflApiError(`Season not found for year: ${year}`));
1870
+ }
1871
+ return ok(season.id);
954
1872
  }
955
- const client = new AflApiClient();
956
- if (query.matchId) {
957
- const rosterResult = await client.fetchMatchRoster(query.matchId);
958
- if (!rosterResult.success) return rosterResult;
959
- return ok([transformMatchRoster(rosterResult.data, query.season, query.round, competition)]);
1873
+ /**
1874
+ * Resolve a season ID from a competition code and year in one step.
1875
+ *
1876
+ * @param code - The competition code (e.g. "AFLM").
1877
+ * @param year - The season year (e.g. 2025).
1878
+ * @returns The compseason ID on success.
1879
+ */
1880
+ async resolveCompSeason(code, year) {
1881
+ const compResult = await this.resolveCompetitionId(code);
1882
+ if (!compResult.success) return compResult;
1883
+ return this.resolveSeasonId(compResult.data, year);
960
1884
  }
961
- const seasonResult = await client.resolveCompSeason(competition, query.season);
962
- if (!seasonResult.success) return seasonResult;
963
- const matchItems = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
964
- if (!matchItems.success) return matchItems;
965
- if (matchItems.data.length === 0) {
966
- return err(new AflApiError(`No matches found for round ${query.round}`));
1885
+ /**
1886
+ * Fetch all rounds for a season with their metadata.
1887
+ *
1888
+ * @param seasonId - The compseason ID (from {@link resolveSeasonId}).
1889
+ * @returns Array of round objects on success.
1890
+ */
1891
+ async resolveRounds(seasonId) {
1892
+ const result = await this.fetchJson(
1893
+ `${API_BASE}/compseasons/${seasonId}/rounds?pageSize=50`,
1894
+ RoundListSchema
1895
+ );
1896
+ if (!result.success) {
1897
+ return result;
1898
+ }
1899
+ return ok(result.data.rounds);
1900
+ }
1901
+ /**
1902
+ * Fetch match items for a round using the /cfs/ endpoint.
1903
+ *
1904
+ * @param roundProviderId - The round provider ID (e.g. "CD_R202501401").
1905
+ * @returns Array of match items on success.
1906
+ */
1907
+ async fetchRoundMatchItems(roundProviderId) {
1908
+ const result = await this.fetchJson(
1909
+ `${CFS_BASE}/matchItems/round/${roundProviderId}`,
1910
+ MatchItemListSchema
1911
+ );
1912
+ if (!result.success) {
1913
+ return result;
1914
+ }
1915
+ return ok(result.data.items);
967
1916
  }
968
- const rosterResults = await Promise.all(
969
- matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
970
- );
971
- const lineups = [];
972
- for (const rosterResult of rosterResults) {
973
- if (!rosterResult.success) return rosterResult;
974
- lineups.push(transformMatchRoster(rosterResult.data, query.season, query.round, competition));
1917
+ /**
1918
+ * Fetch match items for a round by resolving the round provider ID from season and round number.
1919
+ *
1920
+ * @param seasonId - The compseason ID.
1921
+ * @param roundNumber - The round number.
1922
+ * @returns Array of match items on success.
1923
+ */
1924
+ async fetchRoundMatchItemsByNumber(seasonId, roundNumber) {
1925
+ const roundsResult = await this.resolveRounds(seasonId);
1926
+ if (!roundsResult.success) {
1927
+ return roundsResult;
1928
+ }
1929
+ const round = roundsResult.data.find((r) => r.roundNumber === roundNumber);
1930
+ if (!round?.providerId) {
1931
+ return err(new AflApiError(`Round not found or missing providerId: round ${roundNumber}`));
1932
+ }
1933
+ return this.fetchRoundMatchItems(round.providerId);
975
1934
  }
976
- return ok(lineups);
977
- }
978
-
979
- // src/sources/afl-tables.ts
980
- import * as cheerio from "cheerio";
981
-
982
- // src/lib/date-utils.ts
983
- function parseAflApiDate(iso) {
984
- const date = new Date(iso);
985
- if (Number.isNaN(date.getTime())) {
986
- return null;
1935
+ /**
1936
+ * Fetch match items for all completed rounds in a season.
1937
+ *
1938
+ * @param seasonId - The compseason ID.
1939
+ * @returns Aggregated array of match items from all completed rounds.
1940
+ */
1941
+ async fetchSeasonMatchItems(seasonId) {
1942
+ const roundsResult = await this.resolveRounds(seasonId);
1943
+ if (!roundsResult.success) {
1944
+ return roundsResult;
1945
+ }
1946
+ const providerIds = roundsResult.data.flatMap((r) => r.providerId ? [r.providerId] : []);
1947
+ const results = await Promise.all(providerIds.map((id) => this.fetchRoundMatchItems(id)));
1948
+ const allItems = [];
1949
+ for (const result of results) {
1950
+ if (!result.success) {
1951
+ return result;
1952
+ }
1953
+ const concluded = result.data.filter(
1954
+ (item) => item.match.status === "CONCLUDED" || item.match.status === "COMPLETE"
1955
+ );
1956
+ allItems.push(...concluded);
1957
+ }
1958
+ return ok(allItems);
987
1959
  }
988
- return date;
989
- }
990
- function parseFootyWireDate(dateStr) {
991
- const trimmed = dateStr.trim();
992
- if (trimmed === "") {
993
- return null;
1960
+ /**
1961
+ * Fetch per-player statistics for a match.
1962
+ *
1963
+ * @param matchProviderId - The match provider ID (e.g. "CD_M20250140101").
1964
+ * @returns Player stats list with home and away arrays.
1965
+ */
1966
+ async fetchPlayerStats(matchProviderId) {
1967
+ return this.fetchJson(
1968
+ `${CFS_BASE}/playerStats/match/${matchProviderId}`,
1969
+ PlayerStatsListSchema
1970
+ );
994
1971
  }
995
- const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
996
- const normalised = withoutDow.replace(/-/g, " ");
997
- const match = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
998
- if (!match) {
999
- return null;
1972
+ /**
1973
+ * Fetch match roster (lineup) for a match.
1974
+ *
1975
+ * @param matchProviderId - The match provider ID (e.g. "CD_M20250140101").
1976
+ * @returns Match roster with team players.
1977
+ */
1978
+ async fetchMatchRoster(matchProviderId) {
1979
+ return this.fetchJson(`${CFS_BASE}/matchRoster/full/${matchProviderId}`, MatchRosterSchema);
1000
1980
  }
1001
- const [, dayStr, monthStr, yearStr] = match;
1002
- if (!dayStr || !monthStr || !yearStr) {
1003
- return null;
1981
+ /**
1982
+ * Fetch team list, optionally filtered by team type.
1983
+ *
1984
+ * @param teamType - Optional filter (e.g. "MEN", "WOMEN").
1985
+ * @returns Array of team items.
1986
+ */
1987
+ async fetchTeams(teamType) {
1988
+ const result = await this.fetchJson(`${API_BASE}/teams?pageSize=100`, TeamListSchema);
1989
+ if (!result.success) {
1990
+ return result;
1991
+ }
1992
+ if (teamType) {
1993
+ return ok(result.data.teams.filter((t) => t.teamType === teamType));
1994
+ }
1995
+ return ok(result.data.teams);
1004
1996
  }
1005
- const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
1006
- if (monthIndex === void 0) {
1007
- return null;
1997
+ /**
1998
+ * Fetch squad (roster) for a team in a specific season.
1999
+ *
2000
+ * @param teamId - The numeric team ID.
2001
+ * @param compSeasonId - The compseason ID.
2002
+ * @returns Squad list response.
2003
+ */
2004
+ async fetchSquad(teamId, compSeasonId) {
2005
+ return this.fetchJson(
2006
+ `${API_BASE}/squads?teamId=${teamId}&compSeasonId=${compSeasonId}`,
2007
+ SquadListSchema
2008
+ );
1008
2009
  }
1009
- const year = Number.parseInt(yearStr, 10);
1010
- const day = Number.parseInt(dayStr, 10);
1011
- const date = new Date(Date.UTC(year, monthIndex, day));
1012
- if (Number.isNaN(date.getTime())) {
1013
- return null;
2010
+ /**
2011
+ * Fetch ladder standings for a season (optionally for a specific round).
2012
+ *
2013
+ * @param seasonId - The compseason ID.
2014
+ * @param roundId - Optional round ID (numeric `id`, not `providerId`).
2015
+ * @returns Ladder response with entries.
2016
+ */
2017
+ async fetchLadder(seasonId, roundId) {
2018
+ let url = `${API_BASE}/compseasons/${seasonId}/ladders`;
2019
+ if (roundId != null) {
2020
+ url += `?roundId=${roundId}`;
2021
+ }
2022
+ return this.fetchJson(url, LadderResponseSchema);
1014
2023
  }
1015
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
1016
- return null;
2024
+ };
2025
+
2026
+ // src/lib/squiggle-validation.ts
2027
+ import { z as z2 } from "zod";
2028
+ var SquiggleGameSchema = z2.object({
2029
+ id: z2.number(),
2030
+ year: z2.number(),
2031
+ round: z2.number(),
2032
+ roundname: z2.string(),
2033
+ hteam: z2.string(),
2034
+ ateam: z2.string(),
2035
+ hteamid: z2.number(),
2036
+ ateamid: z2.number(),
2037
+ hscore: z2.number().nullable(),
2038
+ ascore: z2.number().nullable(),
2039
+ hgoals: z2.number().nullable(),
2040
+ agoals: z2.number().nullable(),
2041
+ hbehinds: z2.number().nullable(),
2042
+ abehinds: z2.number().nullable(),
2043
+ winner: z2.string().nullable(),
2044
+ winnerteamid: z2.number().nullable(),
2045
+ venue: z2.string(),
2046
+ date: z2.string(),
2047
+ localtime: z2.string(),
2048
+ tz: z2.string(),
2049
+ unixtime: z2.number(),
2050
+ timestr: z2.string().nullable(),
2051
+ complete: z2.number(),
2052
+ is_final: z2.number(),
2053
+ is_grand_final: z2.number(),
2054
+ updated: z2.string()
2055
+ });
2056
+ var SquiggleGamesResponseSchema = z2.object({
2057
+ games: z2.array(SquiggleGameSchema)
2058
+ });
2059
+ var SquiggleStandingSchema = z2.object({
2060
+ id: z2.number(),
2061
+ name: z2.string(),
2062
+ rank: z2.number(),
2063
+ played: z2.number(),
2064
+ wins: z2.number(),
2065
+ losses: z2.number(),
2066
+ draws: z2.number(),
2067
+ pts: z2.number(),
2068
+ for: z2.number(),
2069
+ against: z2.number(),
2070
+ percentage: z2.number(),
2071
+ goals_for: z2.number(),
2072
+ goals_against: z2.number(),
2073
+ behinds_for: z2.number(),
2074
+ behinds_against: z2.number()
2075
+ });
2076
+ var SquiggleStandingsResponseSchema = z2.object({
2077
+ standings: z2.array(SquiggleStandingSchema)
2078
+ });
2079
+
2080
+ // src/sources/squiggle.ts
2081
+ var SQUIGGLE_BASE = "https://api.squiggle.com.au/";
2082
+ var USER_AGENT = "fitzRoy-ts/1.0 (https://github.com/jackemcpherson/fitzRoy-ts)";
2083
+ var SquiggleClient = class {
2084
+ fetchFn;
2085
+ constructor(options) {
2086
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch;
1017
2087
  }
1018
- return date;
1019
- }
1020
- function parseAflTablesDate(dateStr) {
1021
- const trimmed = dateStr.trim();
1022
- if (trimmed === "") {
1023
- return null;
2088
+ /**
2089
+ * Fetch JSON from the Squiggle API.
2090
+ */
2091
+ async fetchJson(params) {
2092
+ const url = `${SQUIGGLE_BASE}?${params.toString()}`;
2093
+ try {
2094
+ const response = await this.fetchFn(url, {
2095
+ headers: { "User-Agent": USER_AGENT }
2096
+ });
2097
+ if (!response.ok) {
2098
+ return err(
2099
+ new ScrapeError(`Squiggle request failed: ${response.status} (${url})`, "squiggle")
2100
+ );
2101
+ }
2102
+ const json = await response.json();
2103
+ return ok(json);
2104
+ } catch (cause) {
2105
+ return err(
2106
+ new ScrapeError(
2107
+ `Squiggle request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2108
+ "squiggle"
2109
+ )
2110
+ );
2111
+ }
1024
2112
  }
1025
- const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
1026
- const normalised = withoutDow.replace(/[-/]/g, " ");
1027
- const dmy = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
1028
- if (dmy) {
1029
- const [, dayStr, monthStr, yearStr] = dmy;
1030
- if (dayStr && monthStr && yearStr) {
1031
- return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
2113
+ /**
2114
+ * Fetch games (match results or fixture) from the Squiggle API.
2115
+ *
2116
+ * @param year - Season year.
2117
+ * @param round - Optional round number.
2118
+ * @param complete - Optional completion filter (100 = complete, omit for all).
2119
+ */
2120
+ async fetchGames(year, round, complete) {
2121
+ const params = new URLSearchParams({ q: "games", year: String(year) });
2122
+ if (round != null) params.set("round", String(round));
2123
+ if (complete != null) params.set("complete", String(complete));
2124
+ const result = await this.fetchJson(params);
2125
+ if (!result.success) return result;
2126
+ const parsed = SquiggleGamesResponseSchema.safeParse(result.data);
2127
+ if (!parsed.success) {
2128
+ return err(
2129
+ new ScrapeError(`Invalid Squiggle games response: ${parsed.error.message}`, "squiggle")
2130
+ );
1032
2131
  }
2132
+ return ok(parsed.data);
1033
2133
  }
1034
- const mdy = /^([A-Za-z]+)\s+(\d{1,2})\s+(\d{4})$/.exec(normalised);
1035
- if (mdy) {
1036
- const [, monthStr, dayStr, yearStr] = mdy;
1037
- if (dayStr && monthStr && yearStr) {
1038
- return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
2134
+ /**
2135
+ * Fetch standings (ladder) from the Squiggle API.
2136
+ *
2137
+ * @param year - Season year.
2138
+ * @param round - Optional round number.
2139
+ */
2140
+ async fetchStandings(year, round) {
2141
+ const params = new URLSearchParams({ q: "standings", year: String(year) });
2142
+ if (round != null) params.set("round", String(round));
2143
+ const result = await this.fetchJson(params);
2144
+ if (!result.success) return result;
2145
+ const parsed = SquiggleStandingsResponseSchema.safeParse(result.data);
2146
+ if (!parsed.success) {
2147
+ return err(
2148
+ new ScrapeError(`Invalid Squiggle standings response: ${parsed.error.message}`, "squiggle")
2149
+ );
1039
2150
  }
2151
+ return ok(parsed.data);
1040
2152
  }
1041
- return null;
2153
+ };
2154
+
2155
+ // src/transforms/squiggle.ts
2156
+ function toMatchStatus2(complete) {
2157
+ if (complete === 100) return "Complete";
2158
+ if (complete > 0) return "Live";
2159
+ return "Upcoming";
1042
2160
  }
1043
- function toAestString(date) {
1044
- const formatter = new Intl.DateTimeFormat("en-AU", {
1045
- timeZone: "Australia/Melbourne",
1046
- weekday: "short",
1047
- day: "numeric",
1048
- month: "short",
1049
- year: "numeric",
1050
- hour: "numeric",
1051
- minute: "2-digit",
1052
- hour12: true,
1053
- timeZoneName: "short"
1054
- });
1055
- return formatter.format(date);
2161
+ function transformSquiggleGamesToResults(games, season) {
2162
+ return games.filter((g) => g.complete === 100).map((g) => ({
2163
+ matchId: `SQ_${g.id}`,
2164
+ season,
2165
+ roundNumber: g.round,
2166
+ roundType: inferRoundType(g.roundname),
2167
+ date: new Date(g.unixtime * 1e3),
2168
+ venue: g.venue,
2169
+ homeTeam: normaliseTeamName(g.hteam),
2170
+ awayTeam: normaliseTeamName(g.ateam),
2171
+ homeGoals: g.hgoals ?? 0,
2172
+ homeBehinds: g.hbehinds ?? 0,
2173
+ homePoints: g.hscore ?? 0,
2174
+ awayGoals: g.agoals ?? 0,
2175
+ awayBehinds: g.abehinds ?? 0,
2176
+ awayPoints: g.ascore ?? 0,
2177
+ margin: (g.hscore ?? 0) - (g.ascore ?? 0),
2178
+ q1Home: null,
2179
+ q2Home: null,
2180
+ q3Home: null,
2181
+ q4Home: null,
2182
+ q1Away: null,
2183
+ q2Away: null,
2184
+ q3Away: null,
2185
+ q4Away: null,
2186
+ status: "Complete",
2187
+ attendance: null,
2188
+ venueState: null,
2189
+ venueTimezone: g.tz || null,
2190
+ homeRushedBehinds: null,
2191
+ awayRushedBehinds: null,
2192
+ homeMinutesInFront: null,
2193
+ awayMinutesInFront: null,
2194
+ source: "squiggle",
2195
+ competition: "AFLM"
2196
+ }));
1056
2197
  }
1057
- var MONTH_ABBREV_TO_INDEX = /* @__PURE__ */ new Map([
1058
- ["jan", 0],
1059
- ["feb", 1],
1060
- ["mar", 2],
1061
- ["apr", 3],
1062
- ["may", 4],
1063
- ["jun", 5],
1064
- ["jul", 6],
1065
- ["aug", 7],
1066
- ["sep", 8],
1067
- ["oct", 9],
1068
- ["nov", 10],
1069
- ["dec", 11],
1070
- ["january", 0],
1071
- ["february", 1],
1072
- ["march", 2],
1073
- ["april", 3],
1074
- ["june", 5],
1075
- ["july", 6],
1076
- ["august", 7],
1077
- ["september", 8],
1078
- ["october", 9],
1079
- ["november", 10],
1080
- ["december", 11]
1081
- ]);
1082
- function buildUtcDate(year, monthStr, day) {
1083
- const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
1084
- if (monthIndex === void 0) {
1085
- return null;
2198
+ function transformSquiggleGamesToFixture(games, season) {
2199
+ return games.map((g) => ({
2200
+ matchId: `SQ_${g.id}`,
2201
+ season,
2202
+ roundNumber: g.round,
2203
+ roundType: inferRoundType(g.roundname),
2204
+ date: new Date(g.unixtime * 1e3),
2205
+ venue: g.venue,
2206
+ homeTeam: normaliseTeamName(g.hteam),
2207
+ awayTeam: normaliseTeamName(g.ateam),
2208
+ status: toMatchStatus2(g.complete),
2209
+ competition: "AFLM"
2210
+ }));
2211
+ }
2212
+ function transformSquiggleStandings(standings) {
2213
+ return standings.map((s) => ({
2214
+ position: s.rank,
2215
+ team: normaliseTeamName(s.name),
2216
+ played: s.played,
2217
+ wins: s.wins,
2218
+ losses: s.losses,
2219
+ draws: s.draws,
2220
+ pointsFor: s.for,
2221
+ pointsAgainst: s.against,
2222
+ percentage: s.percentage,
2223
+ premiershipsPoints: s.pts,
2224
+ form: null
2225
+ }));
2226
+ }
2227
+
2228
+ // src/api/fixture.ts
2229
+ function toFixture(item, season, fallbackRoundNumber, competition) {
2230
+ return {
2231
+ matchId: item.match.matchId,
2232
+ season,
2233
+ roundNumber: item.round?.roundNumber ?? fallbackRoundNumber,
2234
+ roundType: inferRoundType(item.round?.name ?? ""),
2235
+ date: new Date(item.match.utcStartTime),
2236
+ venue: item.venue?.name ?? "",
2237
+ homeTeam: normaliseTeamName(item.match.homeTeam.name),
2238
+ awayTeam: normaliseTeamName(item.match.awayTeam.name),
2239
+ status: toMatchStatus(item.match.status),
2240
+ competition
2241
+ };
2242
+ }
2243
+ async function fetchFixture(query) {
2244
+ const competition = query.competition ?? "AFLM";
2245
+ if (query.source === "squiggle") {
2246
+ const client2 = new SquiggleClient();
2247
+ const result = await client2.fetchGames(query.season, query.round ?? void 0);
2248
+ if (!result.success) return result;
2249
+ return ok(transformSquiggleGamesToFixture(result.data.games, query.season));
1086
2250
  }
1087
- const date = new Date(Date.UTC(year, monthIndex, day));
1088
- if (Number.isNaN(date.getTime())) {
1089
- return null;
2251
+ if (query.source === "footywire") {
2252
+ const fwClient = new FootyWireClient();
2253
+ const result = await fwClient.fetchSeasonFixture(query.season);
2254
+ if (!result.success) return result;
2255
+ if (query.round != null) {
2256
+ return ok(result.data.filter((f) => f.roundNumber === query.round));
2257
+ }
2258
+ return result;
1090
2259
  }
1091
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
1092
- return null;
2260
+ if (query.source !== "afl-api") {
2261
+ return err(
2262
+ new UnsupportedSourceError(
2263
+ "Fixture data is only available from the AFL API, FootyWire, or Squiggle sources.",
2264
+ query.source
2265
+ )
2266
+ );
1093
2267
  }
1094
- return date;
2268
+ const client = new AflApiClient();
2269
+ const seasonResult = await client.resolveCompSeason(competition, query.season);
2270
+ if (!seasonResult.success) return seasonResult;
2271
+ if (query.round != null) {
2272
+ const itemsResult = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
2273
+ if (!itemsResult.success) return itemsResult;
2274
+ return ok(itemsResult.data.map((item) => toFixture(item, query.season, 0, competition)));
2275
+ }
2276
+ const roundsResult = await client.resolveRounds(seasonResult.data);
2277
+ if (!roundsResult.success) return roundsResult;
2278
+ const roundProviderIds = roundsResult.data.flatMap(
2279
+ (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
2280
+ );
2281
+ const roundResults = await Promise.all(
2282
+ roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
2283
+ );
2284
+ const fixtures = [];
2285
+ for (let i = 0; i < roundResults.length; i++) {
2286
+ const result = roundResults[i];
2287
+ if (!result?.success) continue;
2288
+ const roundNumber = roundProviderIds[i]?.roundNumber ?? 0;
2289
+ for (const item of result.data) {
2290
+ fixtures.push(toFixture(item, query.season, roundNumber, competition));
2291
+ }
2292
+ }
2293
+ return ok(fixtures);
2294
+ }
2295
+
2296
+ // src/sources/afl-tables.ts
2297
+ import * as cheerio6 from "cheerio";
2298
+
2299
+ // src/transforms/afl-tables-player-stats.ts
2300
+ import * as cheerio5 from "cheerio";
2301
+ function parseName(raw) {
2302
+ const cleaned = raw.replace(/[↑↓]/g, "").trim();
2303
+ const parts = cleaned.split(",").map((s) => s.trim());
2304
+ const surname = parts[0] ?? "";
2305
+ const givenName = parts[1] ?? "";
2306
+ return {
2307
+ givenName,
2308
+ surname,
2309
+ displayName: givenName ? `${givenName} ${surname}` : surname
2310
+ };
2311
+ }
2312
+ function parseAflTablesGameStats(html, matchId, season, roundNumber) {
2313
+ const $ = cheerio5.load(html);
2314
+ const stats = [];
2315
+ $("table.sortable").each((_tableIdx, table) => {
2316
+ const headerText = $(table).find("thead tr").first().text().trim();
2317
+ const teamMatch = /^(\w[\w\s]+?)\s+Match Statistics/i.exec(headerText);
2318
+ if (!teamMatch) return;
2319
+ const teamName = normaliseTeamName(teamMatch[1]?.trim() ?? "");
2320
+ $(table).find("tbody tr").each((_rowIdx, row) => {
2321
+ const cells = $(row).find("td").map((_, c) => $(c).text().trim()).get();
2322
+ if (cells.length < 24) return;
2323
+ const jumperStr = cells[0] ?? "";
2324
+ const jumperNumber = safeInt(jumperStr.replace(/[↑↓]/g, ""));
2325
+ const { givenName, surname, displayName } = parseName(cells[1] ?? "");
2326
+ stats.push({
2327
+ matchId: `AT_${matchId}`,
2328
+ season,
2329
+ roundNumber,
2330
+ team: teamName,
2331
+ competition: "AFLM",
2332
+ playerId: `AT_${displayName.replace(/\s+/g, "_")}`,
2333
+ givenName,
2334
+ surname,
2335
+ displayName,
2336
+ jumperNumber,
2337
+ kicks: safeInt(cells[2] ?? ""),
2338
+ handballs: safeInt(cells[4] ?? ""),
2339
+ disposals: safeInt(cells[5] ?? ""),
2340
+ marks: safeInt(cells[3] ?? ""),
2341
+ goals: safeInt(cells[6] ?? ""),
2342
+ behinds: safeInt(cells[7] ?? ""),
2343
+ tackles: safeInt(cells[9] ?? ""),
2344
+ hitouts: safeInt(cells[8] ?? ""),
2345
+ freesFor: safeInt(cells[14] ?? ""),
2346
+ freesAgainst: safeInt(cells[15] ?? ""),
2347
+ contestedPossessions: safeInt(cells[17] ?? ""),
2348
+ uncontestedPossessions: safeInt(cells[18] ?? ""),
2349
+ contestedMarks: safeInt(cells[19] ?? ""),
2350
+ intercepts: null,
2351
+ centreClearances: null,
2352
+ stoppageClearances: null,
2353
+ totalClearances: safeInt(cells[12] ?? ""),
2354
+ inside50s: safeInt(cells[11] ?? ""),
2355
+ rebound50s: safeInt(cells[10] ?? ""),
2356
+ clangers: safeInt(cells[13] ?? ""),
2357
+ turnovers: null,
2358
+ onePercenters: safeInt(cells[21] ?? ""),
2359
+ bounces: safeInt(cells[22] ?? ""),
2360
+ goalAssists: safeInt(cells[23] ?? ""),
2361
+ disposalEfficiency: null,
2362
+ metresGained: null,
2363
+ goalAccuracy: null,
2364
+ marksInside50: safeInt(cells[20] ?? ""),
2365
+ tacklesInside50: null,
2366
+ shotsAtGoal: null,
2367
+ scoreInvolvements: null,
2368
+ totalPossessions: null,
2369
+ timeOnGroundPercentage: safeInt(cells[24] ?? ""),
2370
+ ratingPoints: null,
2371
+ dreamTeamPoints: null,
2372
+ effectiveDisposals: null,
2373
+ effectiveKicks: null,
2374
+ kickEfficiency: null,
2375
+ kickToHandballRatio: null,
2376
+ pressureActs: null,
2377
+ defHalfPressureActs: null,
2378
+ spoils: null,
2379
+ hitoutsToAdvantage: null,
2380
+ hitoutWinPercentage: null,
2381
+ hitoutToAdvantageRate: null,
2382
+ groundBallGets: null,
2383
+ f50GroundBallGets: null,
2384
+ interceptMarks: null,
2385
+ marksOnLead: null,
2386
+ contestedPossessionRate: null,
2387
+ contestOffOneOnOnes: null,
2388
+ contestOffWins: null,
2389
+ contestOffWinsPercentage: null,
2390
+ contestDefOneOnOnes: null,
2391
+ contestDefLosses: null,
2392
+ contestDefLossPercentage: null,
2393
+ centreBounceAttendances: null,
2394
+ kickins: null,
2395
+ kickinsPlayon: null,
2396
+ ruckContests: null,
2397
+ scoreLaunches: null,
2398
+ source: "afl-tables"
2399
+ });
2400
+ });
2401
+ });
2402
+ return stats;
2403
+ }
2404
+ function extractGameUrls(seasonHtml) {
2405
+ const $ = cheerio5.load(seasonHtml);
2406
+ const urls = [];
2407
+ $("tr:nth-child(2) td:nth-child(4) a").each((_i, el) => {
2408
+ const href = $(el).attr("href");
2409
+ if (href) {
2410
+ urls.push(href.replace("..", "https://afltables.com/afl"));
2411
+ }
2412
+ });
2413
+ return urls;
1095
2414
  }
1096
2415
 
1097
2416
  // src/sources/afl-tables.ts
@@ -1130,9 +2449,145 @@ var AflTablesClient = class {
1130
2449
  );
1131
2450
  }
1132
2451
  }
2452
+ /**
2453
+ * Fetch player statistics for an entire season from AFL Tables.
2454
+ *
2455
+ * Scrapes individual game pages linked from the season page.
2456
+ *
2457
+ * @param year - The season year.
2458
+ */
2459
+ async fetchSeasonPlayerStats(year) {
2460
+ const seasonUrl = `${AFL_TABLES_BASE}/${year}.html`;
2461
+ try {
2462
+ const seasonResponse = await this.fetchFn(seasonUrl, {
2463
+ headers: { "User-Agent": "Mozilla/5.0" }
2464
+ });
2465
+ if (!seasonResponse.ok) {
2466
+ return err(
2467
+ new ScrapeError(
2468
+ `AFL Tables request failed: ${seasonResponse.status} (${seasonUrl})`,
2469
+ "afl-tables"
2470
+ )
2471
+ );
2472
+ }
2473
+ const seasonHtml = await seasonResponse.text();
2474
+ const gameUrls = extractGameUrls(seasonHtml);
2475
+ if (gameUrls.length === 0) {
2476
+ return ok([]);
2477
+ }
2478
+ const results = parseSeasonPage(seasonHtml, year);
2479
+ const allStats = [];
2480
+ const batchSize = 5;
2481
+ for (let i = 0; i < gameUrls.length; i += batchSize) {
2482
+ const batch = gameUrls.slice(i, i + batchSize);
2483
+ const batchResults = await Promise.all(
2484
+ batch.map(async (gameUrl, batchIdx) => {
2485
+ try {
2486
+ const resp = await this.fetchFn(gameUrl, {
2487
+ headers: { "User-Agent": "Mozilla/5.0" }
2488
+ });
2489
+ if (!resp.ok) return [];
2490
+ const html = await resp.text();
2491
+ const urlMatch = /\/(\d+)\.html$/.exec(gameUrl);
2492
+ const matchId = urlMatch?.[1] ?? `${year}_${i + batchIdx}`;
2493
+ const globalIdx = i + batchIdx;
2494
+ const roundNumber = results[globalIdx]?.roundNumber ?? 0;
2495
+ return parseAflTablesGameStats(html, matchId, year, roundNumber);
2496
+ } catch {
2497
+ return [];
2498
+ }
2499
+ })
2500
+ );
2501
+ for (const stats of batchResults) {
2502
+ allStats.push(...stats);
2503
+ }
2504
+ if (i + batchSize < gameUrls.length) {
2505
+ await new Promise((resolve) => setTimeout(resolve, 300));
2506
+ }
2507
+ }
2508
+ return ok(allStats);
2509
+ } catch (cause) {
2510
+ return err(
2511
+ new ScrapeError(
2512
+ `AFL Tables player stats failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2513
+ "afl-tables"
2514
+ )
2515
+ );
2516
+ }
2517
+ }
2518
+ /**
2519
+ * Fetch team statistics from AFL Tables.
2520
+ *
2521
+ * Scrapes the season stats page which includes per-team aggregate stats.
2522
+ *
2523
+ * @param year - The season year.
2524
+ * @returns Array of team stats entries.
2525
+ */
2526
+ async fetchTeamStats(year) {
2527
+ const url = `https://afltables.com/afl/stats/${year}s.html`;
2528
+ try {
2529
+ const response = await this.fetchFn(url, {
2530
+ headers: { "User-Agent": "Mozilla/5.0" }
2531
+ });
2532
+ if (!response.ok) {
2533
+ return err(
2534
+ new ScrapeError(
2535
+ `AFL Tables stats request failed: ${response.status} (${url})`,
2536
+ "afl-tables"
2537
+ )
2538
+ );
2539
+ }
2540
+ const html = await response.text();
2541
+ const entries = parseAflTablesTeamStats(html, year);
2542
+ return ok(entries);
2543
+ } catch (cause) {
2544
+ return err(
2545
+ new ScrapeError(
2546
+ `AFL Tables team stats failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2547
+ "afl-tables"
2548
+ )
2549
+ );
2550
+ }
2551
+ }
2552
+ /**
2553
+ * Fetch player list from AFL Tables team page.
2554
+ *
2555
+ * Scrapes the team index page (e.g. `teams/swans_idx.html`) which lists
2556
+ * all players who have played for that team historically.
2557
+ *
2558
+ * @param teamName - Canonical team name (e.g. "Sydney Swans").
2559
+ * @returns Array of player details (without source/competition fields).
2560
+ */
2561
+ async fetchPlayerList(teamName) {
2562
+ const slug = teamNameToAflTablesSlug(teamName);
2563
+ if (!slug) {
2564
+ return err(new ScrapeError(`No AFL Tables slug mapping for team: ${teamName}`, "afl-tables"));
2565
+ }
2566
+ const url = `https://afltables.com/afl/stats/alltime/${slug}.html`;
2567
+ try {
2568
+ const response = await this.fetchFn(url, {
2569
+ headers: { "User-Agent": "Mozilla/5.0" }
2570
+ });
2571
+ if (!response.ok) {
2572
+ return err(
2573
+ new ScrapeError(`AFL Tables request failed: ${response.status} (${url})`, "afl-tables")
2574
+ );
2575
+ }
2576
+ const html = await response.text();
2577
+ const players = parseAflTablesPlayerList(html, teamName);
2578
+ return ok(players);
2579
+ } catch (cause) {
2580
+ return err(
2581
+ new ScrapeError(
2582
+ `AFL Tables player list failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2583
+ "afl-tables"
2584
+ )
2585
+ );
2586
+ }
2587
+ }
1133
2588
  };
1134
2589
  function parseSeasonPage(html, year) {
1135
- const $ = cheerio.load(html);
2590
+ const $ = cheerio6.load(html);
1136
2591
  const results = [];
1137
2592
  let currentRound = 0;
1138
2593
  let currentRoundType = "HomeAndAway";
@@ -1140,17 +2595,18 @@ function parseSeasonPage(html, year) {
1140
2595
  $("table").each((_i, table) => {
1141
2596
  const $table = $(table);
1142
2597
  const text = $table.text().trim();
2598
+ const border = $table.attr("border");
1143
2599
  const roundMatch = /^Round\s+(\d+)/i.exec(text);
1144
- if (roundMatch?.[1] && !$table.attr("border")) {
2600
+ if (roundMatch?.[1] && border !== "1") {
1145
2601
  currentRound = Number.parseInt(roundMatch[1], 10);
1146
2602
  currentRoundType = inferRoundType(text);
1147
2603
  return;
1148
2604
  }
1149
- if (!$table.attr("border") && inferRoundType(text) === "Finals") {
2605
+ if (border !== "1" && inferRoundType(text) === "Finals") {
1150
2606
  currentRoundType = "Finals";
1151
2607
  return;
1152
2608
  }
1153
- if ($table.attr("border") !== "1") return;
2609
+ if (border !== "1") return;
1154
2610
  const rows = $table.find("tr");
1155
2611
  if (rows.length !== 2) return;
1156
2612
  const homeRow = $(rows[0]);
@@ -1227,7 +2683,7 @@ function parseDateFromInfo(text, year) {
1227
2683
  return parseAflTablesDate(text) ?? new Date(year, 0, 1);
1228
2684
  }
1229
2685
  function parseVenueFromInfo(html) {
1230
- const $ = cheerio.load(html);
2686
+ const $ = cheerio6.load(html);
1231
2687
  const venueLink = $("a[href*='venues']");
1232
2688
  if (venueLink.length > 0) {
1233
2689
  return venueLink.text().trim();
@@ -1240,144 +2696,342 @@ function parseAttendanceFromInfo(text) {
1240
2696
  if (!match?.[1]) return null;
1241
2697
  return Number.parseInt(match[1].replace(/,/g, ""), 10) || null;
1242
2698
  }
2699
+ function parseAflTablesTeamStats(html, year) {
2700
+ const $ = cheerio6.load(html);
2701
+ const teamMap = /* @__PURE__ */ new Map();
2702
+ const tables = $("table");
2703
+ function parseTable(tableIdx, suffix) {
2704
+ if (tableIdx >= tables.length) return;
2705
+ const $table = $(tables[tableIdx]);
2706
+ const rows = $table.find("tr");
2707
+ if (rows.length < 2) return;
2708
+ const headers = [];
2709
+ $(rows[0]).find("td, th").each((_ci, cell) => {
2710
+ headers.push($(cell).text().trim());
2711
+ });
2712
+ for (let ri = 1; ri < rows.length; ri++) {
2713
+ const cells = $(rows[ri]).find("td");
2714
+ if (cells.length < 3) continue;
2715
+ const teamText = $(cells[0]).text().trim();
2716
+ if (teamText === "Totals" || !teamText) continue;
2717
+ const teamName = normaliseTeamName(teamText);
2718
+ if (!teamName) continue;
2719
+ if (!teamMap.has(teamName)) {
2720
+ teamMap.set(teamName, { gamesPlayed: 0, stats: {} });
2721
+ }
2722
+ const entry = teamMap.get(teamName);
2723
+ if (!entry) continue;
2724
+ for (let ci = 1; ci < cells.length; ci++) {
2725
+ const header = headers[ci];
2726
+ if (!header) continue;
2727
+ const value = Number.parseFloat($(cells[ci]).text().trim().replace(/,/g, "")) || 0;
2728
+ entry.stats[`${header}${suffix}`] = value;
2729
+ }
2730
+ }
2731
+ }
2732
+ parseTable(1, "_for");
2733
+ parseTable(2, "_against");
2734
+ const entries = [];
2735
+ for (const [team, data] of teamMap) {
2736
+ entries.push({
2737
+ season: year,
2738
+ team,
2739
+ gamesPlayed: data.gamesPlayed,
2740
+ stats: data.stats,
2741
+ source: "afl-tables"
2742
+ });
2743
+ }
2744
+ return entries;
2745
+ }
2746
+ var AFL_TABLES_SLUG_MAP = /* @__PURE__ */ new Map([
2747
+ ["Adelaide Crows", "adelaide"],
2748
+ ["Brisbane Lions", "brisbane"],
2749
+ ["Carlton", "carlton"],
2750
+ ["Collingwood", "collingwood"],
2751
+ ["Essendon", "essendon"],
2752
+ ["Fremantle", "fremantle"],
2753
+ ["Geelong Cats", "geelong"],
2754
+ ["Gold Coast Suns", "goldcoast"],
2755
+ ["GWS Giants", "gws"],
2756
+ ["Hawthorn", "hawthorn"],
2757
+ ["Melbourne", "melbourne"],
2758
+ ["North Melbourne", "kangaroos"],
2759
+ ["Port Adelaide", "padelaide"],
2760
+ ["Richmond", "richmond"],
2761
+ ["St Kilda", "stkilda"],
2762
+ ["Sydney Swans", "swans"],
2763
+ ["West Coast Eagles", "westcoast"],
2764
+ ["Western Bulldogs", "bullldogs"],
2765
+ ["Fitzroy", "fitzroy"],
2766
+ ["University", "university"]
2767
+ ]);
2768
+ function teamNameToAflTablesSlug(teamName) {
2769
+ return AFL_TABLES_SLUG_MAP.get(teamName);
2770
+ }
2771
+ function parseAflTablesPlayerList(html, teamName) {
2772
+ const $ = cheerio6.load(html);
2773
+ const players = [];
2774
+ const table = $("table.sortable").first();
2775
+ if (table.length === 0) return players;
2776
+ const rows = table.find("tbody tr");
2777
+ rows.each((_ri, row) => {
2778
+ const cells = $(row).find("td");
2779
+ if (cells.length < 8) return;
2780
+ const jumperText = $(cells[1]).text().trim();
2781
+ const playerText = $(cells[2]).text().trim();
2782
+ if (!playerText) return;
2783
+ const nameParts = playerText.split(",").map((s) => s.trim());
2784
+ const surname = nameParts[0] ?? "";
2785
+ const givenName = nameParts[1] ?? "";
2786
+ const dobText = $(cells[3]).text().trim();
2787
+ const htText = $(cells[4]).text().trim();
2788
+ const wtText = $(cells[5]).text().trim();
2789
+ const gamesRaw = $(cells[6]).text().trim();
2790
+ const gamesMatch = /^(\d+)/.exec(gamesRaw);
2791
+ const goalsText = $(cells[7]).text().trim();
2792
+ const debutText = cells.length > 9 ? $(cells[9]).text().trim() : "";
2793
+ const heightCm = htText ? Number.parseInt(htText, 10) || null : null;
2794
+ const weightKg = wtText ? Number.parseInt(wtText, 10) || null : null;
2795
+ const gamesPlayed = gamesMatch?.[1] ? Number.parseInt(gamesMatch[1], 10) || null : null;
2796
+ const goalsScored = goalsText ? Number.parseInt(goalsText, 10) || null : null;
2797
+ const jumperNumber = jumperText ? Number.parseInt(jumperText, 10) || null : null;
2798
+ const debutYearMatch = /(\d{4})/.exec(debutText);
2799
+ const debutYear = debutYearMatch?.[1] ? Number.parseInt(debutYearMatch[1], 10) || null : null;
2800
+ players.push({
2801
+ playerId: `AT_${teamName}_${surname}_${givenName}`.replace(/\s+/g, "_"),
2802
+ givenName,
2803
+ surname,
2804
+ displayName: givenName ? `${givenName} ${surname}` : surname,
2805
+ team: teamName,
2806
+ jumperNumber,
2807
+ position: null,
2808
+ dateOfBirth: dobText || null,
2809
+ heightCm,
2810
+ weightKg,
2811
+ gamesPlayed,
2812
+ goals: goalsScored,
2813
+ draftYear: null,
2814
+ draftPosition: null,
2815
+ draftType: null,
2816
+ debutYear,
2817
+ recruitedFrom: null
2818
+ });
2819
+ });
2820
+ return players;
2821
+ }
2822
+
2823
+ // src/transforms/computed-ladder.ts
2824
+ function computeLadder(results, upToRound) {
2825
+ const teams = /* @__PURE__ */ new Map();
2826
+ const filtered = upToRound != null ? results.filter((r) => r.roundType === "HomeAndAway" && r.roundNumber <= upToRound) : results.filter((r) => r.roundType === "HomeAndAway");
2827
+ for (const match of filtered) {
2828
+ if (match.status !== "Complete") continue;
2829
+ const home = getOrCreate(teams, match.homeTeam);
2830
+ const away = getOrCreate(teams, match.awayTeam);
2831
+ home.played++;
2832
+ away.played++;
2833
+ home.pointsFor += match.homePoints;
2834
+ home.pointsAgainst += match.awayPoints;
2835
+ away.pointsFor += match.awayPoints;
2836
+ away.pointsAgainst += match.homePoints;
2837
+ if (match.homePoints > match.awayPoints) {
2838
+ home.wins++;
2839
+ away.losses++;
2840
+ } else if (match.awayPoints > match.homePoints) {
2841
+ away.wins++;
2842
+ home.losses++;
2843
+ } else {
2844
+ home.draws++;
2845
+ away.draws++;
2846
+ }
2847
+ }
2848
+ const entries = [...teams.entries()].map(([teamName, acc]) => {
2849
+ const percentage = acc.pointsAgainst === 0 ? 0 : acc.pointsFor / acc.pointsAgainst * 100;
2850
+ const premiershipsPoints = acc.wins * 4 + acc.draws * 2;
2851
+ return {
2852
+ position: 0,
2853
+ // filled below after sorting
2854
+ team: teamName,
2855
+ played: acc.played,
2856
+ wins: acc.wins,
2857
+ losses: acc.losses,
2858
+ draws: acc.draws,
2859
+ pointsFor: acc.pointsFor,
2860
+ pointsAgainst: acc.pointsAgainst,
2861
+ percentage,
2862
+ premiershipsPoints,
2863
+ form: null
2864
+ };
2865
+ });
2866
+ entries.sort((a, b) => {
2867
+ if (b.premiershipsPoints !== a.premiershipsPoints) {
2868
+ return b.premiershipsPoints - a.premiershipsPoints;
2869
+ }
2870
+ return b.percentage - a.percentage;
2871
+ });
2872
+ for (let i = 0; i < entries.length; i++) {
2873
+ const entry = entries[i];
2874
+ if (entry) {
2875
+ entries[i] = { ...entry, position: i + 1 };
2876
+ }
2877
+ }
2878
+ return entries;
2879
+ }
2880
+ function getOrCreate(map, team) {
2881
+ let acc = map.get(team);
2882
+ if (!acc) {
2883
+ acc = { played: 0, wins: 0, losses: 0, draws: 0, pointsFor: 0, pointsAgainst: 0 };
2884
+ map.set(team, acc);
2885
+ }
2886
+ return acc;
2887
+ }
2888
+
2889
+ // src/transforms/ladder.ts
2890
+ function transformLadderEntries(entries) {
2891
+ return entries.map((entry) => {
2892
+ const record = entry.thisSeasonRecord;
2893
+ const wl = record?.winLossRecord;
2894
+ return {
2895
+ position: entry.position,
2896
+ team: normaliseTeamName(entry.team.name),
2897
+ played: entry.played ?? wl?.played ?? 0,
2898
+ wins: wl?.wins ?? 0,
2899
+ losses: wl?.losses ?? 0,
2900
+ draws: wl?.draws ?? 0,
2901
+ pointsFor: entry.pointsFor ?? 0,
2902
+ pointsAgainst: entry.pointsAgainst ?? 0,
2903
+ percentage: record?.percentage ?? 0,
2904
+ premiershipsPoints: record?.aggregatePoints ?? 0,
2905
+ form: entry.form ?? null
2906
+ };
2907
+ });
2908
+ }
2909
+
2910
+ // src/api/ladder.ts
2911
+ async function fetchLadder(query) {
2912
+ const competition = query.competition ?? "AFLM";
2913
+ if (query.source === "squiggle") {
2914
+ const client2 = new SquiggleClient();
2915
+ const result = await client2.fetchStandings(query.season, query.round ?? void 0);
2916
+ if (!result.success) return result;
2917
+ return ok({
2918
+ season: query.season,
2919
+ roundNumber: query.round ?? null,
2920
+ entries: transformSquiggleStandings(result.data.standings),
2921
+ competition
2922
+ });
2923
+ }
2924
+ if (query.source === "afl-tables") {
2925
+ const atClient = new AflTablesClient();
2926
+ const resultsResult = await atClient.fetchSeasonResults(query.season);
2927
+ if (!resultsResult.success) return resultsResult;
2928
+ const entries2 = computeLadder(resultsResult.data, query.round ?? void 0);
2929
+ return ok({
2930
+ season: query.season,
2931
+ roundNumber: query.round ?? null,
2932
+ entries: entries2,
2933
+ competition
2934
+ });
2935
+ }
2936
+ if (query.source !== "afl-api") {
2937
+ return err(
2938
+ new UnsupportedSourceError(
2939
+ "Ladder data is only available from the AFL API, AFL Tables, or Squiggle sources.",
2940
+ query.source
2941
+ )
2942
+ );
2943
+ }
2944
+ const client = new AflApiClient();
2945
+ const seasonResult = await client.resolveCompSeason(competition, query.season);
2946
+ if (!seasonResult.success) return seasonResult;
2947
+ let roundId;
2948
+ if (query.round != null) {
2949
+ const roundsResult = await client.resolveRounds(seasonResult.data);
2950
+ if (!roundsResult.success) return roundsResult;
2951
+ const round = roundsResult.data.find((r) => r.roundNumber === query.round);
2952
+ if (round) {
2953
+ roundId = round.id;
2954
+ }
2955
+ }
2956
+ const ladderResult = await client.fetchLadder(seasonResult.data, roundId);
2957
+ if (!ladderResult.success) return ladderResult;
2958
+ const firstLadder = ladderResult.data.ladders[0];
2959
+ const entries = firstLadder ? transformLadderEntries(firstLadder.entries) : [];
2960
+ return ok({
2961
+ season: query.season,
2962
+ roundNumber: ladderResult.data.round?.roundNumber ?? null,
2963
+ entries,
2964
+ competition
2965
+ });
2966
+ }
2967
+
2968
+ // src/transforms/lineup.ts
2969
+ var EMERGENCY_POSITIONS = /* @__PURE__ */ new Set(["EMG", "EMERG"]);
2970
+ var SUBSTITUTE_POSITIONS = /* @__PURE__ */ new Set(["SUB", "INT"]);
2971
+ function transformMatchRoster(roster, season, roundNumber, competition) {
2972
+ const homeTeamId = roster.match.homeTeamId;
2973
+ const awayTeamId = roster.match.awayTeamId;
2974
+ const homeTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === homeTeamId);
2975
+ const awayTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === awayTeamId);
2976
+ const mapPlayers = (players) => players.map((p) => {
2977
+ const inner = p.player.player;
2978
+ const position = p.player.position ?? null;
2979
+ return {
2980
+ playerId: inner.playerId,
2981
+ givenName: inner.playerName.givenName,
2982
+ surname: inner.playerName.surname,
2983
+ displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
2984
+ jumperNumber: p.jumperNumber ?? null,
2985
+ position,
2986
+ isEmergency: position !== null && EMERGENCY_POSITIONS.has(position),
2987
+ isSubstitute: position !== null && SUBSTITUTE_POSITIONS.has(position)
2988
+ };
2989
+ });
2990
+ return {
2991
+ matchId: roster.match.matchId,
2992
+ season,
2993
+ roundNumber,
2994
+ homeTeam: normaliseTeamName(roster.match.homeTeam.name),
2995
+ awayTeam: normaliseTeamName(roster.match.awayTeam.name),
2996
+ homePlayers: homeTeamPlayers ? mapPlayers(homeTeamPlayers.players) : [],
2997
+ awayPlayers: awayTeamPlayers ? mapPlayers(awayTeamPlayers.players) : [],
2998
+ competition
2999
+ };
3000
+ }
1243
3001
 
1244
- // src/sources/footywire.ts
1245
- import * as cheerio2 from "cheerio";
1246
- var FOOTYWIRE_BASE = "https://www.footywire.com/afl/footy";
1247
- var FootyWireClient = class {
1248
- fetchFn;
1249
- constructor(options) {
1250
- this.fetchFn = options?.fetchFn ?? globalThis.fetch;
3002
+ // src/api/lineup.ts
3003
+ async function fetchLineup(query) {
3004
+ const competition = query.competition ?? "AFLM";
3005
+ if (query.source !== "afl-api") {
3006
+ return err(
3007
+ new UnsupportedSourceError(
3008
+ "Lineup data is only available from the AFL API source.",
3009
+ query.source
3010
+ )
3011
+ );
1251
3012
  }
1252
- /**
1253
- * Fetch the HTML content of a FootyWire page.
1254
- */
1255
- async fetchHtml(url) {
1256
- try {
1257
- const response = await this.fetchFn(url, {
1258
- headers: {
1259
- "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"
1260
- }
1261
- });
1262
- if (!response.ok) {
1263
- return err(
1264
- new ScrapeError(`FootyWire request failed: ${response.status} (${url})`, "footywire")
1265
- );
1266
- }
1267
- const html = await response.text();
1268
- return ok(html);
1269
- } catch (cause) {
1270
- return err(
1271
- new ScrapeError(
1272
- `FootyWire request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1273
- "footywire"
1274
- )
1275
- );
1276
- }
3013
+ const client = new AflApiClient();
3014
+ if (query.matchId) {
3015
+ const rosterResult = await client.fetchMatchRoster(query.matchId);
3016
+ if (!rosterResult.success) return rosterResult;
3017
+ return ok([transformMatchRoster(rosterResult.data, query.season, query.round, competition)]);
1277
3018
  }
1278
- /**
1279
- * Fetch season match results from FootyWire.
1280
- *
1281
- * @param year - The season year.
1282
- * @returns Array of match results.
1283
- */
1284
- async fetchSeasonResults(year) {
1285
- const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
1286
- const htmlResult = await this.fetchHtml(url);
1287
- if (!htmlResult.success) {
1288
- return htmlResult;
1289
- }
1290
- try {
1291
- const results = parseMatchList(htmlResult.data, year);
1292
- return ok(results);
1293
- } catch (cause) {
1294
- return err(
1295
- new ScrapeError(
1296
- `Failed to parse match list: ${cause instanceof Error ? cause.message : String(cause)}`,
1297
- "footywire"
1298
- )
1299
- );
1300
- }
3019
+ const seasonResult = await client.resolveCompSeason(competition, query.season);
3020
+ if (!seasonResult.success) return seasonResult;
3021
+ const matchItems = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
3022
+ if (!matchItems.success) return matchItems;
3023
+ if (matchItems.data.length === 0) {
3024
+ return err(new AflApiError(`No matches found for round ${query.round}`));
1301
3025
  }
1302
- };
1303
- function parseMatchList(html, year) {
1304
- const $ = cheerio2.load(html);
1305
- const results = [];
1306
- let currentRound = 0;
1307
- let currentRoundType = "HomeAndAway";
1308
- $("tr").each((_i, row) => {
1309
- const roundHeader = $(row).find("td[colspan='7']");
1310
- if (roundHeader.length > 0) {
1311
- const text = roundHeader.text().trim();
1312
- currentRoundType = inferRoundType(text);
1313
- const roundMatch = /Round\s+(\d+)/i.exec(text);
1314
- if (roundMatch?.[1]) {
1315
- currentRound = Number.parseInt(roundMatch[1], 10);
1316
- }
1317
- return;
1318
- }
1319
- const cells = $(row).find("td.data");
1320
- if (cells.length < 5) return;
1321
- const dateText = $(cells[0]).text().trim();
1322
- const teamsCell = $(cells[1]);
1323
- const venue = $(cells[2]).text().trim();
1324
- const attendance = $(cells[3]).text().trim();
1325
- const scoreCell = $(cells[4]);
1326
- if (venue === "BYE") return;
1327
- const teamLinks = teamsCell.find("a");
1328
- if (teamLinks.length < 2) return;
1329
- const homeTeam = normaliseTeamName($(teamLinks[0]).text().trim());
1330
- const awayTeam = normaliseTeamName($(teamLinks[1]).text().trim());
1331
- const scoreText = scoreCell.text().trim();
1332
- const scoreMatch = /(\d+)-(\d+)/.exec(scoreText);
1333
- if (!scoreMatch) return;
1334
- const homePoints = Number.parseInt(scoreMatch[1] ?? "0", 10);
1335
- const awayPoints = Number.parseInt(scoreMatch[2] ?? "0", 10);
1336
- const scoreLink = scoreCell.find("a").attr("href") ?? "";
1337
- const midMatch = /mid=(\d+)/.exec(scoreLink);
1338
- const matchId = midMatch?.[1] ? `FW_${midMatch[1]}` : `FW_${year}_R${currentRound}_${homeTeam}`;
1339
- const date = parseFootyWireDate(dateText) ?? new Date(year, 0, 1);
1340
- const homeGoals = Math.floor(homePoints / 6);
1341
- const homeBehinds = homePoints - homeGoals * 6;
1342
- const awayGoals = Math.floor(awayPoints / 6);
1343
- const awayBehinds = awayPoints - awayGoals * 6;
1344
- results.push({
1345
- matchId,
1346
- season: year,
1347
- roundNumber: currentRound,
1348
- roundType: currentRoundType,
1349
- date,
1350
- venue,
1351
- homeTeam,
1352
- awayTeam,
1353
- homeGoals,
1354
- homeBehinds,
1355
- homePoints,
1356
- awayGoals,
1357
- awayBehinds,
1358
- awayPoints,
1359
- margin: homePoints - awayPoints,
1360
- q1Home: null,
1361
- q2Home: null,
1362
- q3Home: null,
1363
- q4Home: null,
1364
- q1Away: null,
1365
- q2Away: null,
1366
- q3Away: null,
1367
- q4Away: null,
1368
- status: "Complete",
1369
- attendance: attendance ? Number.parseInt(attendance, 10) || null : null,
1370
- venueState: null,
1371
- venueTimezone: null,
1372
- homeRushedBehinds: null,
1373
- awayRushedBehinds: null,
1374
- homeMinutesInFront: null,
1375
- awayMinutesInFront: null,
1376
- source: "footywire",
1377
- competition: "AFLM"
1378
- });
1379
- });
1380
- return results;
3026
+ const rosterResults = await Promise.all(
3027
+ matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
3028
+ );
3029
+ const lineups = [];
3030
+ for (const rosterResult of rosterResults) {
3031
+ if (!rosterResult.success) return rosterResult;
3032
+ lineups.push(transformMatchRoster(rosterResult.data, query.season, query.round, competition));
3033
+ }
3034
+ return ok(lineups);
1381
3035
  }
1382
3036
 
1383
3037
  // src/api/match-results.ts
@@ -1418,11 +3072,113 @@ async function fetchMatchResults(query) {
1418
3072
  }
1419
3073
  return result;
1420
3074
  }
3075
+ case "squiggle": {
3076
+ const client = new SquiggleClient();
3077
+ const result = await client.fetchGames(query.season, query.round ?? void 0, 100);
3078
+ if (!result.success) return result;
3079
+ return ok(transformSquiggleGamesToResults(result.data.games, query.season));
3080
+ }
1421
3081
  default:
1422
3082
  return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
1423
3083
  }
1424
3084
  }
1425
3085
 
3086
+ // src/api/player-details.ts
3087
+ async function resolveTeamId(client, teamName, competition) {
3088
+ const teamType = competition === "AFLW" ? "WOMEN" : "MEN";
3089
+ const result = await client.fetchTeams(teamType);
3090
+ if (!result.success) return result;
3091
+ const normalised = normaliseTeamName(teamName);
3092
+ const match = result.data.find((t) => normaliseTeamName(t.name) === normalised);
3093
+ if (!match) {
3094
+ return err(new ValidationError(`Team not found: ${teamName}`));
3095
+ }
3096
+ return ok(String(match.id));
3097
+ }
3098
+ async function fetchFromAflApi(query) {
3099
+ const client = new AflApiClient();
3100
+ const competition = query.competition ?? "AFLM";
3101
+ const season = query.season ?? (/* @__PURE__ */ new Date()).getFullYear();
3102
+ const [teamIdResult, seasonResult] = await Promise.all([
3103
+ resolveTeamId(client, query.team, competition),
3104
+ client.resolveCompSeason(competition, season)
3105
+ ]);
3106
+ if (!teamIdResult.success) return teamIdResult;
3107
+ if (!seasonResult.success) return seasonResult;
3108
+ const teamId = Number.parseInt(teamIdResult.data, 10);
3109
+ if (Number.isNaN(teamId)) {
3110
+ return err(new ValidationError(`Invalid team ID: ${teamIdResult.data}`));
3111
+ }
3112
+ const squadResult = await client.fetchSquad(teamId, seasonResult.data);
3113
+ if (!squadResult.success) return squadResult;
3114
+ const teamName = normaliseTeamName(squadResult.data.squad.team?.name ?? query.team);
3115
+ const players = squadResult.data.squad.players.map((p) => ({
3116
+ playerId: p.player.providerId ?? String(p.player.id),
3117
+ givenName: p.player.firstName,
3118
+ surname: p.player.surname,
3119
+ displayName: `${p.player.firstName} ${p.player.surname}`,
3120
+ team: teamName,
3121
+ jumperNumber: p.jumperNumber ?? null,
3122
+ position: p.position ?? null,
3123
+ dateOfBirth: p.player.dateOfBirth ?? null,
3124
+ heightCm: p.player.heightInCm ?? null,
3125
+ weightKg: p.player.weightInKg ?? null,
3126
+ gamesPlayed: null,
3127
+ goals: null,
3128
+ draftYear: p.player.draftYear ? Number.parseInt(p.player.draftYear, 10) || null : null,
3129
+ draftPosition: p.player.draftPosition ? Number.parseInt(p.player.draftPosition, 10) || null : null,
3130
+ draftType: p.player.draftType ?? null,
3131
+ debutYear: p.player.debutYear ? Number.parseInt(p.player.debutYear, 10) || null : null,
3132
+ recruitedFrom: p.player.recruitedFrom ?? null,
3133
+ source: "afl-api",
3134
+ competition
3135
+ }));
3136
+ return ok(players);
3137
+ }
3138
+ async function fetchFromFootyWire(query) {
3139
+ const client = new FootyWireClient();
3140
+ const competition = query.competition ?? "AFLM";
3141
+ const teamName = normaliseTeamName(query.team);
3142
+ const result = await client.fetchPlayerList(teamName);
3143
+ if (!result.success) return result;
3144
+ const players = result.data.map((p) => ({
3145
+ ...p,
3146
+ source: "footywire",
3147
+ competition
3148
+ }));
3149
+ return ok(players);
3150
+ }
3151
+ async function fetchFromAflTables(query) {
3152
+ const client = new AflTablesClient();
3153
+ const competition = query.competition ?? "AFLM";
3154
+ const teamName = normaliseTeamName(query.team);
3155
+ const result = await client.fetchPlayerList(teamName);
3156
+ if (!result.success) return result;
3157
+ const players = result.data.map((p) => ({
3158
+ ...p,
3159
+ source: "afl-tables",
3160
+ competition
3161
+ }));
3162
+ return ok(players);
3163
+ }
3164
+ async function fetchPlayerDetails(query) {
3165
+ switch (query.source) {
3166
+ case "afl-api":
3167
+ return fetchFromAflApi(query);
3168
+ case "footywire":
3169
+ return fetchFromFootyWire(query);
3170
+ case "afl-tables":
3171
+ return fetchFromAflTables(query);
3172
+ default:
3173
+ return err(
3174
+ new UnsupportedSourceError(
3175
+ `Source "${query.source}" is not supported for player details. Use "afl-api", "footywire", or "afl-tables".`,
3176
+ query.source
3177
+ )
3178
+ );
3179
+ }
3180
+ }
3181
+
1426
3182
  // src/transforms/player-stats.ts
1427
3183
  function toNullable(value) {
1428
3184
  return value ?? null;
@@ -1572,18 +3328,67 @@ async function fetchPlayerStats(query) {
1572
3328
  }
1573
3329
  return ok(allStats);
1574
3330
  }
1575
- case "footywire":
1576
- return err(
1577
- new UnsupportedSourceError(
1578
- "Player stats from FootyWire are not yet supported. Use source: 'afl-api'.",
1579
- "footywire"
1580
- )
1581
- );
1582
- case "afl-tables":
3331
+ case "footywire": {
3332
+ const fwClient = new FootyWireClient();
3333
+ const idsResult = await fwClient.fetchSeasonMatchIds(query.season);
3334
+ if (!idsResult.success) return idsResult;
3335
+ const matchIds = idsResult.data;
3336
+ if (matchIds.length === 0) {
3337
+ return ok([]);
3338
+ }
3339
+ const allStats = [];
3340
+ const batchSize = 5;
3341
+ for (let i = 0; i < matchIds.length; i += batchSize) {
3342
+ const batch = matchIds.slice(i, i + batchSize);
3343
+ const results = await Promise.all(
3344
+ batch.map((mid) => fwClient.fetchMatchPlayerStats(mid, query.season, query.round ?? 0))
3345
+ );
3346
+ for (const result of results) {
3347
+ if (result.success) {
3348
+ allStats.push(...result.data);
3349
+ }
3350
+ }
3351
+ if (i + batchSize < matchIds.length) {
3352
+ await new Promise((resolve) => setTimeout(resolve, 500));
3353
+ }
3354
+ }
3355
+ if (query.round != null) {
3356
+ return ok(allStats.filter((s) => s.roundNumber === query.round));
3357
+ }
3358
+ return ok(allStats);
3359
+ }
3360
+ case "afl-tables": {
3361
+ const atClient = new AflTablesClient();
3362
+ const atResult = await atClient.fetchSeasonPlayerStats(query.season);
3363
+ if (!atResult.success) return atResult;
3364
+ if (query.round != null) {
3365
+ return ok(atResult.data.filter((s) => s.roundNumber === query.round));
3366
+ }
3367
+ return atResult;
3368
+ }
3369
+ default:
3370
+ return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
3371
+ }
3372
+ }
3373
+
3374
+ // src/api/team-stats.ts
3375
+ async function fetchTeamStats(query) {
3376
+ const summaryType = query.summaryType ?? "totals";
3377
+ switch (query.source) {
3378
+ case "footywire": {
3379
+ const client = new FootyWireClient();
3380
+ return client.fetchTeamStats(query.season, summaryType);
3381
+ }
3382
+ case "afl-tables": {
3383
+ const client = new AflTablesClient();
3384
+ return client.fetchTeamStats(query.season);
3385
+ }
3386
+ case "afl-api":
3387
+ case "squiggle":
1583
3388
  return err(
1584
3389
  new UnsupportedSourceError(
1585
- "Player stats from AFL Tables are not yet supported. Use source: 'afl-api'.",
1586
- "afl-tables"
3390
+ `Team stats are not available from ${query.source}. Use "footywire" or "afl-tables".`,
3391
+ query.source
1587
3392
  )
1588
3393
  );
1589
3394
  default:
@@ -1597,18 +3402,17 @@ function teamTypeForComp(comp) {
1597
3402
  }
1598
3403
  async function fetchTeams(query) {
1599
3404
  const client = new AflApiClient();
1600
- const teamType = query?.teamType ?? (query?.competition ? teamTypeForComp(query.competition) : void 0);
3405
+ const teamType = query?.teamType ?? teamTypeForComp(query?.competition ?? "AFLM");
1601
3406
  const result = await client.fetchTeams(teamType);
1602
3407
  if (!result.success) return result;
1603
3408
  const competition = query?.competition ?? "AFLM";
1604
- return ok(
1605
- result.data.map((t) => ({
1606
- teamId: String(t.id),
1607
- name: normaliseTeamName(t.name),
1608
- abbreviation: t.abbreviation ?? "",
1609
- competition
1610
- }))
1611
- );
3409
+ const teams = result.data.map((t) => ({
3410
+ teamId: String(t.id),
3411
+ name: normaliseTeamName(t.name),
3412
+ abbreviation: t.abbreviation ?? "",
3413
+ competition
3414
+ })).filter((t) => AFL_SENIOR_TEAMS.has(t.name));
3415
+ return ok(teams);
1612
3416
  }
1613
3417
  async function fetchSquad(query) {
1614
3418
  const client = new AflApiClient();
@@ -1659,6 +3463,7 @@ export {
1659
3463
  AflApiClient,
1660
3464
  AflApiError,
1661
3465
  AflApiTokenSchema,
3466
+ AflCoachesClient,
1662
3467
  AflTablesClient,
1663
3468
  CfsMatchSchema,
1664
3469
  CfsMatchTeamSchema,
@@ -1687,20 +3492,30 @@ export {
1687
3492
  SquadPlayerInnerSchema,
1688
3493
  SquadPlayerItemSchema,
1689
3494
  SquadSchema,
3495
+ SquiggleClient,
3496
+ SquiggleGameSchema,
3497
+ SquiggleGamesResponseSchema,
3498
+ SquiggleStandingSchema,
3499
+ SquiggleStandingsResponseSchema,
1690
3500
  TeamItemSchema,
1691
3501
  TeamListSchema,
1692
3502
  TeamPlayersSchema,
1693
3503
  TeamScoreSchema,
1694
3504
  UnsupportedSourceError,
1695
3505
  ValidationError,
3506
+ computeLadder,
1696
3507
  err,
3508
+ fetchAwards,
3509
+ fetchCoachesVotes,
1697
3510
  fetchFixture,
1698
3511
  fetchFryziggStats,
1699
3512
  fetchLadder,
1700
3513
  fetchLineup,
1701
3514
  fetchMatchResults,
3515
+ fetchPlayerDetails,
1702
3516
  fetchPlayerStats,
1703
3517
  fetchSquad,
3518
+ fetchTeamStats,
1704
3519
  fetchTeams,
1705
3520
  inferRoundType,
1706
3521
  normaliseTeamName,
@@ -1712,5 +3527,8 @@ export {
1712
3527
  transformLadderEntries,
1713
3528
  transformMatchItems,
1714
3529
  transformMatchRoster,
1715
- transformPlayerStats
3530
+ transformPlayerStats,
3531
+ transformSquiggleGamesToFixture,
3532
+ transformSquiggleGamesToResults,
3533
+ transformSquiggleStandings
1716
3534
  };