fitzroy 1.0.2 → 1.1.1

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 +2968 -847
  2. package/dist/index.d.ts +1380 -916
  3. package/dist/index.js +2907 -1059
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -58,18 +58,123 @@ var init_result = __esm({
58
58
  }
59
59
  });
60
60
 
61
+ // src/lib/date-utils.ts
62
+ function parseFootyWireDate(dateStr, defaultYear) {
63
+ const trimmed = dateStr.trim();
64
+ if (trimmed === "") {
65
+ return null;
66
+ }
67
+ const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
68
+ const normalised = withoutDow.replace(/-/g, " ");
69
+ const fullMatch = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
70
+ if (fullMatch) {
71
+ const [, dayStr, monthStr, yearStr] = fullMatch;
72
+ if (dayStr && monthStr && yearStr) {
73
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
74
+ }
75
+ }
76
+ const shortMatch = /^(\d{1,2})\s+([A-Za-z]+)(?:\s+(\d{1,2}):(\d{2})(am|pm))?$/i.exec(normalised);
77
+ if (shortMatch && defaultYear != null) {
78
+ const [, dayStr, monthStr, hourStr, minStr, ampm] = shortMatch;
79
+ if (!dayStr || !monthStr) return null;
80
+ const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
81
+ if (monthIndex === void 0) return null;
82
+ const day = Number.parseInt(dayStr, 10);
83
+ const hasTime = hourStr && minStr && ampm;
84
+ if (!hasTime) {
85
+ return buildUtcDate(defaultYear, monthStr, day);
86
+ }
87
+ let aestHours = Number.parseInt(hourStr, 10);
88
+ const minutes = Number.parseInt(minStr, 10);
89
+ if (ampm.toLowerCase() === "pm" && aestHours < 12) aestHours += 12;
90
+ if (ampm.toLowerCase() === "am" && aestHours === 12) aestHours = 0;
91
+ const date = new Date(Date.UTC(defaultYear, monthIndex, day, aestHours - 10, minutes));
92
+ if (Number.isNaN(date.getTime())) return null;
93
+ return date;
94
+ }
95
+ return null;
96
+ }
97
+ function parseAflTablesDate(dateStr) {
98
+ const trimmed = dateStr.trim();
99
+ if (trimmed === "") {
100
+ return null;
101
+ }
102
+ const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
103
+ const normalised = withoutDow.replace(/[-/]/g, " ");
104
+ const dmy = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
105
+ if (dmy) {
106
+ const [, dayStr, monthStr, yearStr] = dmy;
107
+ if (dayStr && monthStr && yearStr) {
108
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
109
+ }
110
+ }
111
+ const mdy = /^([A-Za-z]+)\s+(\d{1,2})\s+(\d{4})$/.exec(normalised);
112
+ if (mdy) {
113
+ const [, monthStr, dayStr, yearStr] = mdy;
114
+ if (dayStr && monthStr && yearStr) {
115
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ function buildUtcDate(year, monthStr, day) {
121
+ const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
122
+ if (monthIndex === void 0) {
123
+ return null;
124
+ }
125
+ const date = new Date(Date.UTC(year, monthIndex, day));
126
+ if (Number.isNaN(date.getTime())) {
127
+ return null;
128
+ }
129
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
130
+ return null;
131
+ }
132
+ return date;
133
+ }
134
+ var MONTH_ABBREV_TO_INDEX;
135
+ var init_date_utils = __esm({
136
+ "src/lib/date-utils.ts"() {
137
+ "use strict";
138
+ MONTH_ABBREV_TO_INDEX = /* @__PURE__ */ new Map([
139
+ ["jan", 0],
140
+ ["feb", 1],
141
+ ["mar", 2],
142
+ ["apr", 3],
143
+ ["may", 4],
144
+ ["jun", 5],
145
+ ["jul", 6],
146
+ ["aug", 7],
147
+ ["sep", 8],
148
+ ["oct", 9],
149
+ ["nov", 10],
150
+ ["dec", 11],
151
+ ["january", 0],
152
+ ["february", 1],
153
+ ["march", 2],
154
+ ["april", 3],
155
+ ["june", 5],
156
+ ["july", 6],
157
+ ["august", 7],
158
+ ["september", 8],
159
+ ["october", 9],
160
+ ["november", 10],
161
+ ["december", 11]
162
+ ]);
163
+ }
164
+ });
165
+
61
166
  // src/lib/team-mapping.ts
62
167
  function normaliseTeamName(raw) {
63
168
  const trimmed = raw.trim();
64
169
  return ALIAS_MAP.get(trimmed.toLowerCase()) ?? trimmed;
65
170
  }
66
- var TEAM_ALIASES, ALIAS_MAP;
171
+ var TEAM_ALIASES, AFL_SENIOR_TEAMS, ALIAS_MAP;
67
172
  var init_team_mapping = __esm({
68
173
  "src/lib/team-mapping.ts"() {
69
174
  "use strict";
70
175
  TEAM_ALIASES = [
71
176
  ["Adelaide Crows", "Adelaide", "Crows", "ADEL", "AD"],
72
- ["Brisbane Lions", "Brisbane", "Brisbane Bears", "Bears", "Fitzroy Lions", "BL", "BRIS"],
177
+ ["Brisbane Lions", "Brisbane", "Brisbane Bears", "Bears", "Lions", "Fitzroy Lions", "BL", "BRIS"],
73
178
  ["Carlton", "Carlton Blues", "Blues", "CARL", "CA"],
74
179
  ["Collingwood", "Collingwood Magpies", "Magpies", "COLL", "CW"],
75
180
  ["Essendon", "Essendon Bombers", "Bombers", "ESS", "ES"],
@@ -106,6 +211,26 @@ var init_team_mapping = __esm({
106
211
  ["Fitzroy", "Fitzroy Reds", "Fitzroy Gorillas", "Fitzroy Maroons", "FI"],
107
212
  ["University", "University Blacks"]
108
213
  ];
214
+ AFL_SENIOR_TEAMS = /* @__PURE__ */ new Set([
215
+ "Adelaide Crows",
216
+ "Brisbane Lions",
217
+ "Carlton",
218
+ "Collingwood",
219
+ "Essendon",
220
+ "Fremantle",
221
+ "Geelong Cats",
222
+ "Gold Coast Suns",
223
+ "GWS Giants",
224
+ "Hawthorn",
225
+ "Melbourne",
226
+ "North Melbourne",
227
+ "Port Adelaide",
228
+ "Richmond",
229
+ "St Kilda",
230
+ "Sydney Swans",
231
+ "West Coast Eagles",
232
+ "Western Bulldogs"
233
+ ]);
109
234
  ALIAS_MAP = (() => {
110
235
  const map = /* @__PURE__ */ new Map();
111
236
  for (const [canonical, ...aliases] of TEAM_ALIASES) {
@@ -119,238 +244,1296 @@ var init_team_mapping = __esm({
119
244
  }
120
245
  });
121
246
 
122
- // src/lib/validation.ts
123
- import { z } from "zod/v4";
124
- var AflApiTokenSchema, CompetitionSchema, CompetitionListSchema, CompseasonSchema, CompseasonListSchema, RoundSchema, RoundListSchema, ScoreSchema, PeriodScoreSchema, TeamScoreSchema, CfsMatchTeamSchema, CfsMatchSchema, CfsScoreSchema, CfsVenueSchema, MatchItemSchema, MatchItemListSchema, CfsPlayerInnerSchema, PlayerGameStatsSchema, PlayerStatsItemSchema, PlayerStatsListSchema, RosterPlayerSchema, TeamPlayersSchema, MatchRosterSchema, TeamItemSchema, TeamListSchema, SquadPlayerInnerSchema, SquadPlayerItemSchema, SquadSchema, SquadListSchema, WinLossRecordSchema, LadderEntryRawSchema, LadderResponseSchema;
125
- var init_validation = __esm({
126
- "src/lib/validation.ts"() {
247
+ // src/lib/parse-utils.ts
248
+ function safeInt(text) {
249
+ const cleaned = text.replace(/[^0-9-]/g, "").trim();
250
+ if (!cleaned) return null;
251
+ const n = Number.parseInt(cleaned, 10);
252
+ return Number.isNaN(n) ? null : n;
253
+ }
254
+ function parseIntOr0(text) {
255
+ const n = Number.parseInt(text.replace(/[^0-9-]/g, ""), 10);
256
+ return Number.isNaN(n) ? 0 : n;
257
+ }
258
+ function parseFloatOr0(text) {
259
+ const n = Number.parseFloat(text.replace(/[^0-9.-]/g, ""));
260
+ return Number.isNaN(n) ? 0 : n;
261
+ }
262
+ var init_parse_utils = __esm({
263
+ "src/lib/parse-utils.ts"() {
127
264
  "use strict";
128
- AflApiTokenSchema = z.object({
129
- token: z.string(),
130
- disclaimer: z.string().optional()
131
- }).passthrough();
132
- CompetitionSchema = z.object({
133
- id: z.number(),
134
- name: z.string(),
135
- code: z.string().optional()
136
- }).passthrough();
137
- CompetitionListSchema = z.object({
138
- competitions: z.array(CompetitionSchema)
139
- }).passthrough();
140
- CompseasonSchema = z.object({
141
- id: z.number(),
142
- name: z.string(),
143
- shortName: z.string().optional(),
144
- currentRoundNumber: z.number().optional()
145
- }).passthrough();
146
- CompseasonListSchema = z.object({
147
- compSeasons: z.array(CompseasonSchema)
148
- }).passthrough();
149
- RoundSchema = z.object({
150
- id: z.number(),
151
- /** Provider ID used by /cfs/ endpoints (e.g. "CD_R202501401"). */
152
- providerId: z.string().optional(),
153
- name: z.string(),
154
- abbreviation: z.string().optional(),
155
- roundNumber: z.number(),
156
- utcStartTime: z.string().optional(),
157
- utcEndTime: z.string().optional()
158
- }).passthrough();
159
- RoundListSchema = z.object({
160
- rounds: z.array(RoundSchema)
161
- }).passthrough();
162
- ScoreSchema = z.object({
163
- totalScore: z.number(),
164
- goals: z.number(),
165
- behinds: z.number(),
166
- superGoals: z.number().nullable().optional()
167
- }).passthrough();
168
- PeriodScoreSchema = z.object({
169
- periodNumber: z.number(),
170
- score: ScoreSchema
171
- }).passthrough();
172
- TeamScoreSchema = z.object({
173
- matchScore: ScoreSchema,
174
- periodScore: z.array(PeriodScoreSchema).optional(),
175
- rushedBehinds: z.number().optional(),
176
- minutesInFront: z.number().optional()
177
- }).passthrough();
178
- CfsMatchTeamSchema = z.object({
179
- name: z.string(),
180
- teamId: z.string(),
181
- abbr: z.string().optional(),
182
- nickname: z.string().optional()
183
- }).passthrough();
184
- CfsMatchSchema = z.object({
185
- matchId: z.string(),
186
- name: z.string().optional(),
187
- status: z.string(),
188
- utcStartTime: z.string(),
189
- homeTeamId: z.string(),
190
- awayTeamId: z.string(),
191
- homeTeam: CfsMatchTeamSchema,
192
- awayTeam: CfsMatchTeamSchema,
193
- round: z.string().optional(),
194
- abbr: z.string().optional()
195
- }).passthrough();
196
- CfsScoreSchema = z.object({
197
- status: z.string(),
198
- matchId: z.string(),
199
- homeTeamScore: TeamScoreSchema,
200
- awayTeamScore: TeamScoreSchema
201
- }).passthrough();
202
- CfsVenueSchema = z.object({
203
- name: z.string(),
204
- venueId: z.string().optional(),
205
- state: z.string().optional(),
206
- timeZone: z.string().optional()
207
- }).passthrough();
208
- MatchItemSchema = z.object({
209
- match: CfsMatchSchema,
210
- score: CfsScoreSchema.optional(),
211
- venue: CfsVenueSchema.optional(),
212
- round: z.object({
213
- name: z.string(),
214
- roundId: z.string(),
215
- roundNumber: z.number()
216
- }).passthrough().optional()
217
- }).passthrough();
218
- MatchItemListSchema = z.object({
219
- roundId: z.string().optional(),
220
- items: z.array(MatchItemSchema)
221
- }).passthrough();
222
- CfsPlayerInnerSchema = z.object({
223
- playerId: z.string(),
224
- playerName: z.object({
225
- givenName: z.string(),
226
- surname: z.string()
227
- }).passthrough(),
228
- captain: z.boolean().optional(),
229
- playerJumperNumber: z.number().optional()
230
- }).passthrough();
231
- PlayerGameStatsSchema = z.object({
232
- goals: z.number().optional(),
233
- behinds: z.number().optional(),
234
- kicks: z.number().optional(),
235
- handballs: z.number().optional(),
236
- disposals: z.number().optional(),
237
- marks: z.number().optional(),
238
- bounces: z.number().optional(),
239
- tackles: z.number().optional(),
240
- contestedPossessions: z.number().optional(),
241
- uncontestedPossessions: z.number().optional(),
242
- totalPossessions: z.number().optional(),
243
- inside50s: z.number().optional(),
244
- marksInside50: z.number().optional(),
245
- contestedMarks: z.number().optional(),
246
- hitouts: z.number().optional(),
247
- onePercenters: z.number().optional(),
248
- disposalEfficiency: z.number().optional(),
249
- clangers: z.number().optional(),
250
- freesFor: z.number().optional(),
251
- freesAgainst: z.number().optional(),
252
- dreamTeamPoints: z.number().optional(),
253
- clearances: z.object({
254
- centreClearances: z.number().optional(),
255
- stoppageClearances: z.number().optional(),
256
- totalClearances: z.number().optional()
257
- }).passthrough().optional(),
258
- rebound50s: z.number().optional(),
259
- goalAssists: z.number().optional(),
260
- goalAccuracy: z.number().optional(),
261
- turnovers: z.number().optional(),
262
- intercepts: z.number().optional(),
263
- tacklesInside50: z.number().optional(),
264
- shotsAtGoal: z.number().optional(),
265
- metresGained: z.number().optional(),
266
- scoreInvolvements: z.number().optional(),
267
- ratingPoints: z.number().optional(),
268
- extendedStats: z.object({
269
- effectiveDisposals: z.number().optional(),
270
- effectiveKicks: z.number().optional(),
271
- kickEfficiency: z.number().optional(),
272
- kickToHandballRatio: z.number().optional(),
273
- pressureActs: z.number().optional(),
274
- defHalfPressureActs: z.number().optional(),
275
- spoils: z.number().optional(),
276
- hitoutsToAdvantage: z.number().optional(),
277
- hitoutWinPercentage: z.number().optional(),
278
- hitoutToAdvantageRate: z.number().optional(),
279
- groundBallGets: z.number().optional(),
280
- f50GroundBallGets: z.number().optional(),
281
- interceptMarks: z.number().optional(),
282
- marksOnLead: z.number().optional(),
283
- contestedPossessionRate: z.number().optional(),
284
- contestOffOneOnOnes: z.number().optional(),
285
- contestOffWins: z.number().optional(),
286
- contestOffWinsPercentage: z.number().optional(),
287
- contestDefOneOnOnes: z.number().optional(),
288
- contestDefLosses: z.number().optional(),
289
- contestDefLossPercentage: z.number().optional(),
290
- centreBounceAttendances: z.number().optional(),
291
- kickins: z.number().optional(),
292
- kickinsPlayon: z.number().optional(),
293
- ruckContests: z.number().optional(),
294
- scoreLaunches: z.number().optional()
295
- }).passthrough().optional()
296
- }).passthrough();
297
- PlayerStatsItemSchema = z.object({
298
- player: z.object({
299
- player: z.object({
300
- position: z.string().optional(),
301
- player: CfsPlayerInnerSchema
302
- }).passthrough(),
303
- jumperNumber: z.number().optional()
304
- }).passthrough(),
305
- teamId: z.string(),
306
- playerStats: z.object({
307
- stats: PlayerGameStatsSchema,
308
- timeOnGroundPercentage: z.number().optional()
309
- }).passthrough()
310
- }).passthrough();
311
- PlayerStatsListSchema = z.object({
312
- homeTeamPlayerStats: z.array(PlayerStatsItemSchema),
313
- awayTeamPlayerStats: z.array(PlayerStatsItemSchema)
314
- }).passthrough();
315
- RosterPlayerSchema = z.object({
316
- player: z.object({
317
- position: z.string().optional(),
318
- player: CfsPlayerInnerSchema
319
- }).passthrough(),
320
- jumperNumber: z.number().optional()
321
- }).passthrough();
322
- TeamPlayersSchema = z.object({
323
- teamId: z.string(),
324
- players: z.array(RosterPlayerSchema)
325
- }).passthrough();
326
- MatchRosterSchema = z.object({
327
- match: CfsMatchSchema,
328
- teamPlayers: z.array(TeamPlayersSchema)
329
- }).passthrough();
330
- TeamItemSchema = z.object({
331
- id: z.number(),
332
- name: z.string(),
333
- abbreviation: z.string().optional(),
334
- teamType: z.string().optional()
335
- }).passthrough();
336
- TeamListSchema = z.object({
337
- teams: z.array(TeamItemSchema)
338
- }).passthrough();
339
- SquadPlayerInnerSchema = z.object({
340
- id: z.number(),
341
- providerId: z.string().optional(),
342
- firstName: z.string(),
343
- surname: z.string(),
344
- dateOfBirth: z.string().optional(),
345
- heightInCm: z.number().optional(),
346
- weightInKg: z.number().optional(),
347
- draftYear: z.string().optional(),
348
- draftPosition: z.string().optional(),
349
- draftType: z.string().optional(),
350
- debutYear: z.string().optional(),
351
- recruitedFrom: z.string().optional()
352
- }).passthrough();
353
- SquadPlayerItemSchema = z.object({
265
+ }
266
+ });
267
+
268
+ // src/transforms/footywire-player-stats.ts
269
+ import * as cheerio from "cheerio";
270
+ function cleanPlayerName(raw) {
271
+ return raw.replace(/[↗↙]/g, "").trim();
272
+ }
273
+ function parseStatsTable(html, expectedCols, rowParser) {
274
+ const $ = cheerio.load(html);
275
+ const results = [];
276
+ $("table").each((_i, table) => {
277
+ const rows = $(table).find("tr");
278
+ if (rows.length < 3) return;
279
+ const headerCells = $(rows[0]).find("td, th").map((_, c) => $(c).text().trim()).get();
280
+ if (headerCells[0] !== "Player" || headerCells.length < expectedCols.length) return;
281
+ if (!headerCells.includes(expectedCols[1])) return;
282
+ let teamName = "";
283
+ const parentTable = $(table).closest("table").parent().closest("table");
284
+ const teamHeader = parentTable.find("td:contains('Match Statistics')").first();
285
+ if (teamHeader.length > 0) {
286
+ const headerText = teamHeader.text().trim();
287
+ const match = /^(\w[\w\s]+?)\s+Match Statistics/i.exec(headerText);
288
+ if (match?.[1]) {
289
+ teamName = match[1].trim();
290
+ }
291
+ }
292
+ const parsed = [];
293
+ rows.each((j, row) => {
294
+ if (j === 0) return;
295
+ const cells = $(row).find("td").map((_, c) => $(c).text().trim()).get();
296
+ if (cells.length < expectedCols.length - 1) return;
297
+ const result = rowParser(cells);
298
+ if (result) parsed.push(result);
299
+ });
300
+ if (parsed.length > 0) {
301
+ results.push([teamName, parsed]);
302
+ }
303
+ });
304
+ return results;
305
+ }
306
+ function parseBasicRow(cells) {
307
+ const player = cleanPlayerName(cells[0] ?? "");
308
+ if (!player) return null;
309
+ return {
310
+ player,
311
+ kicks: parseIntOr0(cells[1] ?? "0"),
312
+ handballs: parseIntOr0(cells[2] ?? "0"),
313
+ disposals: parseIntOr0(cells[3] ?? "0"),
314
+ marks: parseIntOr0(cells[4] ?? "0"),
315
+ goals: parseIntOr0(cells[5] ?? "0"),
316
+ behinds: parseIntOr0(cells[6] ?? "0"),
317
+ tackles: parseIntOr0(cells[7] ?? "0"),
318
+ hitouts: parseIntOr0(cells[8] ?? "0"),
319
+ goalAssists: parseIntOr0(cells[9] ?? "0"),
320
+ inside50s: parseIntOr0(cells[10] ?? "0"),
321
+ clearances: parseIntOr0(cells[11] ?? "0"),
322
+ clangers: parseIntOr0(cells[12] ?? "0"),
323
+ rebound50s: parseIntOr0(cells[13] ?? "0"),
324
+ freesFor: parseIntOr0(cells[14] ?? "0"),
325
+ freesAgainst: parseIntOr0(cells[15] ?? "0"),
326
+ dreamTeamPoints: parseIntOr0(cells[16] ?? "0"),
327
+ supercoachPoints: parseIntOr0(cells[17] ?? "0")
328
+ };
329
+ }
330
+ function parseAdvancedRow(cells) {
331
+ const player = cleanPlayerName(cells[0] ?? "");
332
+ if (!player) return null;
333
+ return {
334
+ player,
335
+ contestedPossessions: parseIntOr0(cells[1] ?? "0"),
336
+ uncontestedPossessions: parseIntOr0(cells[2] ?? "0"),
337
+ effectiveDisposals: parseIntOr0(cells[3] ?? "0"),
338
+ disposalEfficiency: parseFloatOr0(cells[4] ?? "0"),
339
+ contestedMarks: parseIntOr0(cells[5] ?? "0"),
340
+ goalAssists: parseIntOr0(cells[6] ?? "0"),
341
+ marksInside50: parseIntOr0(cells[7] ?? "0"),
342
+ onePercenters: parseIntOr0(cells[8] ?? "0"),
343
+ bounces: parseIntOr0(cells[9] ?? "0"),
344
+ centreClearances: parseIntOr0(cells[10] ?? "0"),
345
+ stoppageClearances: parseIntOr0(cells[11] ?? "0"),
346
+ scoreInvolvements: parseIntOr0(cells[12] ?? "0"),
347
+ metresGained: parseIntOr0(cells[13] ?? "0"),
348
+ turnovers: parseIntOr0(cells[14] ?? "0"),
349
+ intercepts: parseIntOr0(cells[15] ?? "0"),
350
+ tacklesInside50: parseIntOr0(cells[16] ?? "0"),
351
+ timeOnGroundPercentage: parseFloatOr0(cells[17] ?? "0")
352
+ };
353
+ }
354
+ function parseBasicStats(html) {
355
+ return parseStatsTable(html, [...BASIC_COLS], parseBasicRow);
356
+ }
357
+ function parseAdvancedStats(html) {
358
+ return parseStatsTable(html, [...ADVANCED_COLS], parseAdvancedRow);
359
+ }
360
+ function mergeFootyWireStats(basicTeams, advancedTeams, matchId, season, roundNumber) {
361
+ const stats = [];
362
+ for (let teamIdx = 0; teamIdx < basicTeams.length; teamIdx++) {
363
+ const basicEntry = basicTeams[teamIdx];
364
+ const advancedEntry = advancedTeams[teamIdx];
365
+ if (!basicEntry) continue;
366
+ const [teamName, basicRows] = basicEntry;
367
+ const advancedRows = advancedEntry?.[1] ?? [];
368
+ const advancedByName = /* @__PURE__ */ new Map();
369
+ for (const adv of advancedRows) {
370
+ advancedByName.set(adv.player.toLowerCase(), adv);
371
+ }
372
+ for (const basic of basicRows) {
373
+ const nameParts = basic.player.split(/\s+/);
374
+ const surname = nameParts[nameParts.length - 1] ?? "";
375
+ const firstName = nameParts.slice(0, -1).join(" ");
376
+ const initial = firstName.charAt(0);
377
+ const abbrevName = `${initial} ${surname}`.toLowerCase();
378
+ const adv = advancedByName.get(abbrevName);
379
+ stats.push({
380
+ matchId: `FW_${matchId}`,
381
+ season,
382
+ roundNumber,
383
+ team: teamName,
384
+ competition: "AFLM",
385
+ playerId: `FW_${basic.player.replace(/\s+/g, "_")}`,
386
+ givenName: firstName,
387
+ surname,
388
+ displayName: basic.player,
389
+ jumperNumber: null,
390
+ kicks: basic.kicks,
391
+ handballs: basic.handballs,
392
+ disposals: basic.disposals,
393
+ marks: basic.marks,
394
+ goals: basic.goals,
395
+ behinds: basic.behinds,
396
+ tackles: basic.tackles,
397
+ hitouts: basic.hitouts,
398
+ freesFor: basic.freesFor,
399
+ freesAgainst: basic.freesAgainst,
400
+ contestedPossessions: adv?.contestedPossessions ?? null,
401
+ uncontestedPossessions: adv?.uncontestedPossessions ?? null,
402
+ contestedMarks: adv?.contestedMarks ?? null,
403
+ intercepts: adv?.intercepts ?? null,
404
+ centreClearances: adv?.centreClearances ?? null,
405
+ stoppageClearances: adv?.stoppageClearances ?? null,
406
+ totalClearances: basic.clearances,
407
+ inside50s: basic.inside50s,
408
+ rebound50s: basic.rebound50s,
409
+ clangers: basic.clangers,
410
+ turnovers: adv?.turnovers ?? null,
411
+ onePercenters: adv?.onePercenters ?? null,
412
+ bounces: adv?.bounces ?? null,
413
+ goalAssists: basic.goalAssists,
414
+ disposalEfficiency: adv?.disposalEfficiency ?? null,
415
+ metresGained: adv?.metresGained ?? null,
416
+ goalAccuracy: null,
417
+ marksInside50: adv?.marksInside50 ?? null,
418
+ tacklesInside50: adv?.tacklesInside50 ?? null,
419
+ shotsAtGoal: null,
420
+ scoreInvolvements: adv?.scoreInvolvements ?? null,
421
+ totalPossessions: null,
422
+ timeOnGroundPercentage: adv?.timeOnGroundPercentage ?? null,
423
+ ratingPoints: null,
424
+ dreamTeamPoints: basic.dreamTeamPoints,
425
+ effectiveDisposals: adv?.effectiveDisposals ?? null,
426
+ effectiveKicks: null,
427
+ kickEfficiency: null,
428
+ kickToHandballRatio: null,
429
+ pressureActs: null,
430
+ defHalfPressureActs: null,
431
+ spoils: null,
432
+ hitoutsToAdvantage: null,
433
+ hitoutWinPercentage: null,
434
+ hitoutToAdvantageRate: null,
435
+ groundBallGets: null,
436
+ f50GroundBallGets: null,
437
+ interceptMarks: null,
438
+ marksOnLead: null,
439
+ contestedPossessionRate: null,
440
+ contestOffOneOnOnes: null,
441
+ contestOffWins: null,
442
+ contestOffWinsPercentage: null,
443
+ contestDefOneOnOnes: null,
444
+ contestDefLosses: null,
445
+ contestDefLossPercentage: null,
446
+ centreBounceAttendances: null,
447
+ kickins: null,
448
+ kickinsPlayon: null,
449
+ ruckContests: null,
450
+ scoreLaunches: null,
451
+ source: "footywire"
452
+ });
453
+ }
454
+ }
455
+ return stats;
456
+ }
457
+ var BASIC_COLS, ADVANCED_COLS;
458
+ var init_footywire_player_stats = __esm({
459
+ "src/transforms/footywire-player-stats.ts"() {
460
+ "use strict";
461
+ init_parse_utils();
462
+ BASIC_COLS = [
463
+ "Player",
464
+ "K",
465
+ "HB",
466
+ "D",
467
+ "M",
468
+ "G",
469
+ "B",
470
+ "T",
471
+ "HO",
472
+ "GA",
473
+ "I50",
474
+ "CL",
475
+ "CG",
476
+ "R50",
477
+ "FF",
478
+ "FA",
479
+ "AF",
480
+ "SC"
481
+ ];
482
+ ADVANCED_COLS = [
483
+ "Player",
484
+ "CP",
485
+ "UP",
486
+ "ED",
487
+ "DE",
488
+ "CM",
489
+ "GA",
490
+ "MI5",
491
+ "1%",
492
+ "BO",
493
+ "CCL",
494
+ "SCL",
495
+ "SI",
496
+ "MG",
497
+ "TO",
498
+ "ITC",
499
+ "T5",
500
+ "TOG"
501
+ ];
502
+ }
503
+ });
504
+
505
+ // src/transforms/match-results.ts
506
+ function inferRoundType(roundName) {
507
+ return FINALS_PATTERN.test(roundName) ? "Finals" : "HomeAndAway";
508
+ }
509
+ function toMatchStatus(raw) {
510
+ switch (raw) {
511
+ case "CONCLUDED":
512
+ case "COMPLETE":
513
+ return "Complete";
514
+ case "LIVE":
515
+ case "IN_PROGRESS":
516
+ return "Live";
517
+ case "UPCOMING":
518
+ case "SCHEDULED":
519
+ return "Upcoming";
520
+ case "POSTPONED":
521
+ return "Postponed";
522
+ case "CANCELLED":
523
+ return "Cancelled";
524
+ default:
525
+ return "Complete";
526
+ }
527
+ }
528
+ function toQuarterScore(period) {
529
+ return {
530
+ goals: period.score.goals,
531
+ behinds: period.score.behinds,
532
+ points: period.score.totalScore
533
+ };
534
+ }
535
+ function findPeriod(periods, quarter) {
536
+ if (!periods) return null;
537
+ const period = periods.find((p) => p.periodNumber === quarter);
538
+ return period ? toQuarterScore(period) : null;
539
+ }
540
+ function transformMatchItems(items, season, competition, source = "afl-api") {
541
+ return items.map((item) => {
542
+ const homeScore = item.score?.homeTeamScore;
543
+ const awayScore = item.score?.awayTeamScore;
544
+ const homePoints = homeScore?.matchScore.totalScore ?? 0;
545
+ const awayPoints = awayScore?.matchScore.totalScore ?? 0;
546
+ return {
547
+ matchId: item.match.matchId,
548
+ season,
549
+ roundNumber: item.round?.roundNumber ?? 0,
550
+ roundType: inferRoundType(item.round?.name ?? ""),
551
+ date: new Date(item.match.utcStartTime),
552
+ venue: item.venue?.name ?? "",
553
+ homeTeam: normaliseTeamName(item.match.homeTeam.name),
554
+ awayTeam: normaliseTeamName(item.match.awayTeam.name),
555
+ homeGoals: homeScore?.matchScore.goals ?? 0,
556
+ homeBehinds: homeScore?.matchScore.behinds ?? 0,
557
+ homePoints,
558
+ awayGoals: awayScore?.matchScore.goals ?? 0,
559
+ awayBehinds: awayScore?.matchScore.behinds ?? 0,
560
+ awayPoints,
561
+ margin: homePoints - awayPoints,
562
+ q1Home: findPeriod(homeScore?.periodScore, 1),
563
+ q2Home: findPeriod(homeScore?.periodScore, 2),
564
+ q3Home: findPeriod(homeScore?.periodScore, 3),
565
+ q4Home: findPeriod(homeScore?.periodScore, 4),
566
+ q1Away: findPeriod(awayScore?.periodScore, 1),
567
+ q2Away: findPeriod(awayScore?.periodScore, 2),
568
+ q3Away: findPeriod(awayScore?.periodScore, 3),
569
+ q4Away: findPeriod(awayScore?.periodScore, 4),
570
+ status: toMatchStatus(item.match.status),
571
+ attendance: null,
572
+ venueState: item.venue?.state ?? null,
573
+ venueTimezone: item.venue?.timeZone ?? null,
574
+ homeRushedBehinds: homeScore?.rushedBehinds ?? null,
575
+ awayRushedBehinds: awayScore?.rushedBehinds ?? null,
576
+ homeMinutesInFront: homeScore?.minutesInFront ?? null,
577
+ awayMinutesInFront: awayScore?.minutesInFront ?? null,
578
+ source,
579
+ competition
580
+ };
581
+ });
582
+ }
583
+ var FINALS_PATTERN;
584
+ var init_match_results = __esm({
585
+ "src/transforms/match-results.ts"() {
586
+ "use strict";
587
+ init_team_mapping();
588
+ FINALS_PATTERN = /final|elimination|qualifying|preliminary|semi|grand/i;
589
+ }
590
+ });
591
+
592
+ // src/sources/footywire.ts
593
+ import * as cheerio2 from "cheerio";
594
+ function parseMatchList(html, year) {
595
+ const $ = cheerio2.load(html);
596
+ const results = [];
597
+ let currentRound = 0;
598
+ let currentRoundType = "HomeAndAway";
599
+ $("tr").each((_i, row) => {
600
+ const roundHeader = $(row).find("td[colspan='7']");
601
+ if (roundHeader.length > 0) {
602
+ const text = roundHeader.text().trim();
603
+ currentRoundType = inferRoundType(text);
604
+ const roundMatch = /Round\s+(\d+)/i.exec(text);
605
+ if (roundMatch?.[1]) {
606
+ currentRound = Number.parseInt(roundMatch[1], 10);
607
+ }
608
+ return;
609
+ }
610
+ const cells = $(row).find("td.data");
611
+ if (cells.length < 5) return;
612
+ const dateText = $(cells[0]).text().trim();
613
+ const teamsCell = $(cells[1]);
614
+ const venue = $(cells[2]).text().trim();
615
+ const attendance = $(cells[3]).text().trim();
616
+ const scoreCell = $(cells[4]);
617
+ if (venue === "BYE") return;
618
+ const teamLinks = teamsCell.find("a");
619
+ if (teamLinks.length < 2) return;
620
+ const homeTeam = normaliseTeamName($(teamLinks[0]).text().trim());
621
+ const awayTeam = normaliseTeamName($(teamLinks[1]).text().trim());
622
+ const scoreText = scoreCell.text().trim();
623
+ const scoreMatch = /(\d+)-(\d+)/.exec(scoreText);
624
+ if (!scoreMatch) return;
625
+ const homePoints = Number.parseInt(scoreMatch[1] ?? "0", 10);
626
+ const awayPoints = Number.parseInt(scoreMatch[2] ?? "0", 10);
627
+ const scoreLink = scoreCell.find("a").attr("href") ?? "";
628
+ const midMatch = /mid=(\d+)/.exec(scoreLink);
629
+ const matchId = midMatch?.[1] ? `FW_${midMatch[1]}` : `FW_${year}_R${currentRound}_${homeTeam}`;
630
+ const date = parseFootyWireDate(dateText, year) ?? new Date(Date.UTC(year, 0, 1));
631
+ const homeGoals = Math.floor(homePoints / 6);
632
+ const homeBehinds = homePoints - homeGoals * 6;
633
+ const awayGoals = Math.floor(awayPoints / 6);
634
+ const awayBehinds = awayPoints - awayGoals * 6;
635
+ results.push({
636
+ matchId,
637
+ season: year,
638
+ roundNumber: currentRound,
639
+ roundType: currentRoundType,
640
+ date,
641
+ venue,
642
+ homeTeam,
643
+ awayTeam,
644
+ homeGoals,
645
+ homeBehinds,
646
+ homePoints,
647
+ awayGoals,
648
+ awayBehinds,
649
+ awayPoints,
650
+ margin: homePoints - awayPoints,
651
+ q1Home: null,
652
+ q2Home: null,
653
+ q3Home: null,
654
+ q4Home: null,
655
+ q1Away: null,
656
+ q2Away: null,
657
+ q3Away: null,
658
+ q4Away: null,
659
+ status: "Complete",
660
+ attendance: attendance ? Number.parseInt(attendance, 10) || null : null,
661
+ venueState: null,
662
+ venueTimezone: null,
663
+ homeRushedBehinds: null,
664
+ awayRushedBehinds: null,
665
+ homeMinutesInFront: null,
666
+ awayMinutesInFront: null,
667
+ source: "footywire",
668
+ competition: "AFLM"
669
+ });
670
+ });
671
+ return results;
672
+ }
673
+ function parseFixtureList(html, year) {
674
+ const $ = cheerio2.load(html);
675
+ const fixtures = [];
676
+ let currentRound = 0;
677
+ let currentRoundType = "HomeAndAway";
678
+ let gameNumber = 0;
679
+ $("tr").each((_i, row) => {
680
+ const roundHeader = $(row).find("td[colspan='7']");
681
+ if (roundHeader.length > 0) {
682
+ const text = roundHeader.text().trim();
683
+ currentRoundType = inferRoundType(text);
684
+ const roundMatch = /Round\s+(\d+)/i.exec(text);
685
+ if (roundMatch?.[1]) {
686
+ currentRound = Number.parseInt(roundMatch[1], 10);
687
+ }
688
+ return;
689
+ }
690
+ const cells = $(row).find("td.data");
691
+ if (cells.length < 3) return;
692
+ const dateText = $(cells[0]).text().trim();
693
+ const teamsCell = $(cells[1]);
694
+ const venue = $(cells[2]).text().trim();
695
+ if (venue === "BYE") return;
696
+ const teamLinks = teamsCell.find("a");
697
+ if (teamLinks.length < 2) return;
698
+ const homeTeam = normaliseTeamName($(teamLinks[0]).text().trim());
699
+ const awayTeam = normaliseTeamName($(teamLinks[1]).text().trim());
700
+ const date = parseFootyWireDate(dateText, year) ?? new Date(Date.UTC(year, 0, 1));
701
+ gameNumber++;
702
+ const scoreCell = cells.length >= 5 ? $(cells[4]) : null;
703
+ const scoreText = scoreCell?.text().trim() ?? "";
704
+ const hasScore = /\d+-\d+/.test(scoreText);
705
+ fixtures.push({
706
+ matchId: `FW_${year}_R${currentRound}_G${gameNumber}`,
707
+ season: year,
708
+ roundNumber: currentRound,
709
+ roundType: currentRoundType,
710
+ date,
711
+ venue,
712
+ homeTeam,
713
+ awayTeam,
714
+ status: hasScore ? "Complete" : "Upcoming",
715
+ competition: "AFLM"
716
+ });
717
+ });
718
+ return fixtures;
719
+ }
720
+ function parseFootyWireTeamStats(html, year, suffix) {
721
+ const $ = cheerio2.load(html);
722
+ const entries = [];
723
+ const tables = $("table");
724
+ const mainTable = tables.length > 10 ? $(tables[10]) : $("table.sortable").first();
725
+ if (mainTable.length === 0) return entries;
726
+ const STAT_KEYS = [
727
+ "K",
728
+ "HB",
729
+ "D",
730
+ "M",
731
+ "G",
732
+ "GA",
733
+ "I50",
734
+ "BH",
735
+ "T",
736
+ "HO",
737
+ "FF",
738
+ "FA",
739
+ "CL",
740
+ "CG",
741
+ "R50",
742
+ "AF",
743
+ "SC"
744
+ ];
745
+ const rows = mainTable.find("tr");
746
+ rows.each((rowIdx, row) => {
747
+ if (rowIdx === 0) return;
748
+ const cells = $(row).find("td");
749
+ if (cells.length < 20) return;
750
+ const teamLink = $(cells[1]).find("a");
751
+ const teamText = teamLink.length > 0 ? teamLink.text().trim() : $(cells[1]).text().trim();
752
+ const teamName = normaliseTeamName(teamText);
753
+ if (!teamName) return;
754
+ const parseNum = (cell) => Number.parseFloat(cell.text().trim()) || 0;
755
+ const gamesPlayed = parseNum($(cells[2]));
756
+ const stats = {};
757
+ for (let i = 0; i < STAT_KEYS.length; i++) {
758
+ const key = suffix === "against" ? `${STAT_KEYS[i]}_against` : STAT_KEYS[i];
759
+ stats[key] = parseNum($(cells[i + 3]));
760
+ }
761
+ entries.push({
762
+ season: year,
763
+ team: teamName,
764
+ gamesPlayed,
765
+ stats,
766
+ source: "footywire"
767
+ });
768
+ });
769
+ return entries;
770
+ }
771
+ function mergeTeamAndOppStats(teamStats, oppStats) {
772
+ const oppMap = /* @__PURE__ */ new Map();
773
+ for (const entry of oppStats) {
774
+ oppMap.set(entry.team, entry.stats);
775
+ }
776
+ return teamStats.map((entry) => {
777
+ const opp = oppMap.get(entry.team);
778
+ if (!opp) return entry;
779
+ return {
780
+ ...entry,
781
+ stats: { ...entry.stats, ...opp }
782
+ };
783
+ });
784
+ }
785
+ function teamNameToFootyWireSlug(teamName) {
786
+ return FOOTYWIRE_SLUG_MAP.get(teamName);
787
+ }
788
+ function normaliseDob(raw) {
789
+ if (!raw) return null;
790
+ const parsed = parseFootyWireDate(raw);
791
+ if (parsed) return parsed.toISOString().slice(0, 10);
792
+ return raw;
793
+ }
794
+ function parseFootyWirePlayerList(html, teamName) {
795
+ const $ = cheerio2.load(html);
796
+ const players = [];
797
+ let dataRows = null;
798
+ $("table").each((_i, table) => {
799
+ const firstRow = $(table).find("tr").first();
800
+ const cells = firstRow.find("td, th");
801
+ const cellTexts = cells.map((_j, c) => $(c).text().trim()).get();
802
+ if (cellTexts.includes("Age") && cellTexts.includes("Name")) {
803
+ dataRows = $(table).find("tr");
804
+ return false;
805
+ }
806
+ });
807
+ if (!dataRows) return players;
808
+ dataRows.each((_rowIdx, row) => {
809
+ const cells = $(row).find("td");
810
+ if (cells.length < 6) return;
811
+ const jumperText = $(cells[0]).text().trim();
812
+ const nameText = $(cells[1]).text().trim();
813
+ const gamesText = $(cells[2]).text().trim();
814
+ const dobText = cells.length > 4 ? $(cells[4]).text().trim() : "";
815
+ const heightText = cells.length > 5 ? $(cells[5]).text().trim() : "";
816
+ const origin = cells.length > 6 ? $(cells[6]).text().trim() : "";
817
+ const position = cells.length > 7 ? $(cells[7]).text().trim() : "";
818
+ const cleanedName = nameText.replace(/\nR$/, "").trim();
819
+ if (!cleanedName || cleanedName === "Name") return;
820
+ const nameParts = cleanedName.split(",").map((s) => s.trim());
821
+ const surname = nameParts[0] ?? "";
822
+ const givenName = nameParts[1] ?? "";
823
+ const jumperNumber = jumperText ? Number.parseInt(jumperText, 10) || null : null;
824
+ const gamesPlayed = gamesText ? Number.parseInt(gamesText, 10) || null : null;
825
+ const heightMatch = /(\d+)cm/.exec(heightText);
826
+ const heightCm = heightMatch?.[1] ? Number.parseInt(heightMatch[1], 10) || null : null;
827
+ players.push({
828
+ playerId: `FW_${teamName}_${surname}_${givenName}`.replace(/\s+/g, "_"),
829
+ givenName,
830
+ surname,
831
+ displayName: givenName ? `${givenName} ${surname}` : surname,
832
+ team: teamName,
833
+ jumperNumber,
834
+ position: position || null,
835
+ dateOfBirth: normaliseDob(dobText),
836
+ heightCm,
837
+ weightKg: null,
838
+ gamesPlayed,
839
+ goals: null,
840
+ draftYear: null,
841
+ draftPosition: null,
842
+ draftType: null,
843
+ debutYear: null,
844
+ recruitedFrom: origin || null
845
+ });
846
+ });
847
+ return players;
848
+ }
849
+ var FOOTYWIRE_BASE, FootyWireClient, FOOTYWIRE_SLUG_MAP;
850
+ var init_footywire = __esm({
851
+ "src/sources/footywire.ts"() {
852
+ "use strict";
853
+ init_date_utils();
854
+ init_errors();
855
+ init_result();
856
+ init_team_mapping();
857
+ init_footywire_player_stats();
858
+ init_match_results();
859
+ FOOTYWIRE_BASE = "https://www.footywire.com/afl/footy";
860
+ FootyWireClient = class {
861
+ fetchFn;
862
+ constructor(options) {
863
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch;
864
+ }
865
+ /**
866
+ * Fetch the HTML content of any URL using this client's fetch function.
867
+ *
868
+ * Public wrapper around the internal fetchHtml for use by external modules
869
+ * (e.g. awards) that need to scrape FootyWire pages.
870
+ */
871
+ async fetchPage(url) {
872
+ return this.fetchHtml(url);
873
+ }
874
+ /**
875
+ * Fetch the HTML content of a FootyWire page.
876
+ */
877
+ async fetchHtml(url) {
878
+ try {
879
+ const response = await this.fetchFn(url, {
880
+ headers: {
881
+ "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"
882
+ }
883
+ });
884
+ if (!response.ok) {
885
+ return err(
886
+ new ScrapeError(`FootyWire request failed: ${response.status} (${url})`, "footywire")
887
+ );
888
+ }
889
+ const html = await response.text();
890
+ return ok(html);
891
+ } catch (cause) {
892
+ return err(
893
+ new ScrapeError(
894
+ `FootyWire request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
895
+ "footywire"
896
+ )
897
+ );
898
+ }
899
+ }
900
+ /**
901
+ * Fetch season match results from FootyWire.
902
+ *
903
+ * @param year - The season year.
904
+ * @returns Array of match results.
905
+ */
906
+ async fetchSeasonResults(year) {
907
+ const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
908
+ const htmlResult = await this.fetchHtml(url);
909
+ if (!htmlResult.success) {
910
+ return htmlResult;
911
+ }
912
+ try {
913
+ const results = parseMatchList(htmlResult.data, year);
914
+ return ok(results);
915
+ } catch (cause) {
916
+ return err(
917
+ new ScrapeError(
918
+ `Failed to parse match list: ${cause instanceof Error ? cause.message : String(cause)}`,
919
+ "footywire"
920
+ )
921
+ );
922
+ }
923
+ }
924
+ /**
925
+ * Fetch player statistics for a single match.
926
+ *
927
+ * Scrapes both the basic and advanced stats pages.
928
+ * Available from 2010 onwards.
929
+ *
930
+ * @param matchId - The FootyWire match ID (numeric string).
931
+ * @param season - The season year.
932
+ * @param roundNumber - The round number.
933
+ */
934
+ async fetchMatchPlayerStats(matchId, season, roundNumber) {
935
+ const basicUrl = `${FOOTYWIRE_BASE}/ft_match_statistics?mid=${matchId}`;
936
+ const advancedUrl = `${FOOTYWIRE_BASE}/ft_match_statistics?mid=${matchId}&advv=Y`;
937
+ const basicResult = await this.fetchHtml(basicUrl);
938
+ if (!basicResult.success) return basicResult;
939
+ const advancedResult = await this.fetchHtml(advancedUrl);
940
+ if (!advancedResult.success) return advancedResult;
941
+ try {
942
+ const basicTeams = parseBasicStats(basicResult.data);
943
+ const advancedTeams = parseAdvancedStats(advancedResult.data);
944
+ const stats = mergeFootyWireStats(basicTeams, advancedTeams, matchId, season, roundNumber);
945
+ return ok(stats);
946
+ } catch (cause) {
947
+ return err(
948
+ new ScrapeError(
949
+ `Failed to parse match stats: ${cause instanceof Error ? cause.message : String(cause)}`,
950
+ "footywire"
951
+ )
952
+ );
953
+ }
954
+ }
955
+ /**
956
+ * Fetch match IDs from a season's match list page.
957
+ *
958
+ * Extracts `mid=XXXX` values from score links.
959
+ *
960
+ * @param year - The season year.
961
+ * @returns Array of match ID strings.
962
+ */
963
+ async fetchSeasonMatchIds(year) {
964
+ const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
965
+ const htmlResult = await this.fetchHtml(url);
966
+ if (!htmlResult.success) return htmlResult;
967
+ try {
968
+ const $ = cheerio2.load(htmlResult.data);
969
+ const ids = [];
970
+ $(".data:nth-child(5) a").each((_i, el) => {
971
+ const href = $(el).attr("href") ?? "";
972
+ const match = /mid=(\d+)/.exec(href);
973
+ if (match?.[1]) {
974
+ ids.push(match[1]);
975
+ }
976
+ });
977
+ return ok(ids);
978
+ } catch (cause) {
979
+ return err(
980
+ new ScrapeError(
981
+ `Failed to parse match IDs: ${cause instanceof Error ? cause.message : String(cause)}`,
982
+ "footywire"
983
+ )
984
+ );
985
+ }
986
+ }
987
+ /**
988
+ * Fetch player list (team history) from FootyWire.
989
+ *
990
+ * Scrapes the team history page (e.g. `th-hawthorn-hawks`) which lists
991
+ * all players who have played for that team.
992
+ *
993
+ * @param teamName - Canonical team name (e.g. "Hawthorn").
994
+ * @returns Array of player details (without source/competition fields).
995
+ */
996
+ async fetchPlayerList(teamName) {
997
+ const slug = teamNameToFootyWireSlug(teamName);
998
+ if (!slug) {
999
+ return err(new ScrapeError(`No FootyWire slug mapping for team: ${teamName}`, "footywire"));
1000
+ }
1001
+ const url = `${FOOTYWIRE_BASE}/tp-${slug}`;
1002
+ const htmlResult = await this.fetchHtml(url);
1003
+ if (!htmlResult.success) return htmlResult;
1004
+ try {
1005
+ const players = parseFootyWirePlayerList(htmlResult.data, teamName);
1006
+ return ok(players);
1007
+ } catch (cause) {
1008
+ return err(
1009
+ new ScrapeError(
1010
+ `Failed to parse player list: ${cause instanceof Error ? cause.message : String(cause)}`,
1011
+ "footywire"
1012
+ )
1013
+ );
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Fetch fixture data from FootyWire.
1018
+ *
1019
+ * Parses the match list page to extract scheduled matches with dates and venues.
1020
+ *
1021
+ * @param year - The season year.
1022
+ * @returns Array of fixture entries.
1023
+ */
1024
+ async fetchSeasonFixture(year) {
1025
+ const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
1026
+ const htmlResult = await this.fetchHtml(url);
1027
+ if (!htmlResult.success) return htmlResult;
1028
+ try {
1029
+ const fixtures = parseFixtureList(htmlResult.data, year);
1030
+ return ok(fixtures);
1031
+ } catch (cause) {
1032
+ return err(
1033
+ new ScrapeError(
1034
+ `Failed to parse fixture list: ${cause instanceof Error ? cause.message : String(cause)}`,
1035
+ "footywire"
1036
+ )
1037
+ );
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Fetch team statistics from FootyWire.
1042
+ *
1043
+ * Scrapes team-level aggregate stats (totals or averages) for a season.
1044
+ *
1045
+ * @param year - The season year.
1046
+ * @param summaryType - "totals" or "averages" (default "totals").
1047
+ * @returns Array of team stats entries.
1048
+ */
1049
+ async fetchTeamStats(year, summaryType = "totals") {
1050
+ const teamType = summaryType === "averages" ? "TA" : "TT";
1051
+ const oppType = summaryType === "averages" ? "OA" : "OT";
1052
+ const teamUrl = `${FOOTYWIRE_BASE}/ft_team_rankings?year=${year}&type=${teamType}&sby=2`;
1053
+ const oppUrl = `${FOOTYWIRE_BASE}/ft_team_rankings?year=${year}&type=${oppType}&sby=2`;
1054
+ const [teamResult, oppResult] = await Promise.all([
1055
+ this.fetchHtml(teamUrl),
1056
+ this.fetchHtml(oppUrl)
1057
+ ]);
1058
+ if (!teamResult.success) return teamResult;
1059
+ if (!oppResult.success) return oppResult;
1060
+ try {
1061
+ const teamStats = parseFootyWireTeamStats(teamResult.data, year, "for");
1062
+ const oppStats = parseFootyWireTeamStats(oppResult.data, year, "against");
1063
+ const merged = mergeTeamAndOppStats(teamStats, oppStats);
1064
+ return ok(merged);
1065
+ } catch (cause) {
1066
+ return err(
1067
+ new ScrapeError(
1068
+ `Failed to parse team stats: ${cause instanceof Error ? cause.message : String(cause)}`,
1069
+ "footywire"
1070
+ )
1071
+ );
1072
+ }
1073
+ }
1074
+ };
1075
+ FOOTYWIRE_SLUG_MAP = /* @__PURE__ */ new Map([
1076
+ ["Adelaide Crows", "adelaide-crows"],
1077
+ ["Brisbane Lions", "brisbane-lions"],
1078
+ ["Carlton", "carlton-blues"],
1079
+ ["Collingwood", "collingwood-magpies"],
1080
+ ["Essendon", "essendon-bombers"],
1081
+ ["Fremantle", "fremantle-dockers"],
1082
+ ["Geelong Cats", "geelong-cats"],
1083
+ ["Gold Coast Suns", "gold-coast-suns"],
1084
+ ["GWS Giants", "greater-western-sydney-giants"],
1085
+ ["Hawthorn", "hawthorn-hawks"],
1086
+ ["Melbourne", "melbourne-demons"],
1087
+ ["North Melbourne", "north-melbourne-kangaroos"],
1088
+ ["Port Adelaide", "port-adelaide-power"],
1089
+ ["Richmond", "richmond-tigers"],
1090
+ ["St Kilda", "st-kilda-saints"],
1091
+ ["Sydney Swans", "sydney-swans"],
1092
+ ["West Coast Eagles", "west-coast-eagles"],
1093
+ ["Western Bulldogs", "western-bulldogs"]
1094
+ ]);
1095
+ }
1096
+ });
1097
+
1098
+ // src/sources/afl-coaches.ts
1099
+ import * as cheerio3 from "cheerio";
1100
+ function parseCoachesVotesHtml(html, season, roundNumber) {
1101
+ const $ = cheerio3.load(html);
1102
+ const clubLogos = $(".pr-md-3.votes-by-match .club_logo");
1103
+ const homeTeams = [];
1104
+ const awayTeams = [];
1105
+ clubLogos.each((i, el) => {
1106
+ const title = $(el).attr("title") ?? "";
1107
+ if (i % 2 === 0) {
1108
+ homeTeams.push(title);
1109
+ } else {
1110
+ awayTeams.push(title);
1111
+ }
1112
+ });
1113
+ const rawVotes = [];
1114
+ $(".pr-md-3.votes-by-match .col-2").each((_i, el) => {
1115
+ const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
1116
+ rawVotes.push(text);
1117
+ });
1118
+ const rawPlayers = [];
1119
+ $(".pr-md-3.votes-by-match .col-10").each((_i, el) => {
1120
+ const text = $(el).text().replace(/\n/g, "").replace(/\t/g, "").trim();
1121
+ rawPlayers.push(text);
1122
+ });
1123
+ const votes = [];
1124
+ let matchIndex = 0;
1125
+ for (let i = 0; i < rawPlayers.length; i++) {
1126
+ const playerName = rawPlayers[i] ?? "";
1127
+ const voteText = rawVotes[i] ?? "";
1128
+ if (playerName === "Player (Club)" && voteText === "Votes") {
1129
+ matchIndex++;
1130
+ continue;
1131
+ }
1132
+ const homeTeam = homeTeams[matchIndex - 1];
1133
+ const awayTeam = awayTeams[matchIndex - 1];
1134
+ if (homeTeam == null || awayTeam == null) {
1135
+ continue;
1136
+ }
1137
+ const voteCount = Number(voteText);
1138
+ if (Number.isNaN(voteCount)) {
1139
+ continue;
1140
+ }
1141
+ votes.push({
1142
+ season,
1143
+ round: roundNumber,
1144
+ homeTeam,
1145
+ awayTeam,
1146
+ playerName,
1147
+ votes: voteCount
1148
+ });
1149
+ }
1150
+ return votes;
1151
+ }
1152
+ var AflCoachesClient;
1153
+ var init_afl_coaches = __esm({
1154
+ "src/sources/afl-coaches.ts"() {
1155
+ "use strict";
1156
+ init_errors();
1157
+ init_result();
1158
+ AflCoachesClient = class {
1159
+ fetchFn;
1160
+ constructor(options) {
1161
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch;
1162
+ }
1163
+ /**
1164
+ * Fetch the HTML content of an AFLCA page.
1165
+ */
1166
+ async fetchHtml(url) {
1167
+ try {
1168
+ const response = await this.fetchFn(url, {
1169
+ headers: {
1170
+ "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"
1171
+ }
1172
+ });
1173
+ if (!response.ok) {
1174
+ return err(
1175
+ new ScrapeError(`AFL Coaches request failed: ${response.status} (${url})`, "afl-coaches")
1176
+ );
1177
+ }
1178
+ const html = await response.text();
1179
+ return ok(html);
1180
+ } catch (cause) {
1181
+ return err(
1182
+ new ScrapeError(
1183
+ `AFL Coaches request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1184
+ "afl-coaches"
1185
+ )
1186
+ );
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Build the AFLCA leaderboard URL for a given season, round, and competition.
1191
+ *
1192
+ * Mirrors the R package URL construction from `helper-aflcoaches.R`.
1193
+ *
1194
+ * @param season - Season year (e.g. 2024).
1195
+ * @param roundNumber - Round number.
1196
+ * @param competition - "AFLM" or "AFLW".
1197
+ * @param isFinals - Whether this is a finals round.
1198
+ */
1199
+ buildUrl(season, roundNumber, competition, isFinals) {
1200
+ 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/";
1201
+ const compSuffix = competition === "AFLW" ? "02" : "01";
1202
+ const secondPart = season >= 2023 ? season + 1 : season;
1203
+ const roundPad = String(roundNumber).padStart(2, "0");
1204
+ return `${linkBase}${season}/${secondPart}${compSuffix}${roundPad}`;
1205
+ }
1206
+ /**
1207
+ * Scrape coaches votes for a single round.
1208
+ *
1209
+ * @param season - Season year.
1210
+ * @param roundNumber - Round number.
1211
+ * @param competition - "AFLM" or "AFLW".
1212
+ * @param isFinals - Whether this is a finals round.
1213
+ * @returns Array of coaches vote records for that round.
1214
+ */
1215
+ async scrapeRoundVotes(season, roundNumber, competition, isFinals) {
1216
+ const url = this.buildUrl(season, roundNumber, competition, isFinals);
1217
+ const htmlResult = await this.fetchHtml(url);
1218
+ if (!htmlResult.success) {
1219
+ return htmlResult;
1220
+ }
1221
+ try {
1222
+ const votes = parseCoachesVotesHtml(htmlResult.data, season, roundNumber);
1223
+ return ok(votes);
1224
+ } catch (cause) {
1225
+ return err(
1226
+ new ScrapeError(
1227
+ `Failed to parse coaches votes: ${cause instanceof Error ? cause.message : String(cause)}`,
1228
+ "afl-coaches"
1229
+ )
1230
+ );
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Fetch coaches votes for an entire season (all rounds).
1235
+ *
1236
+ * Iterates over rounds 1-30, skipping rounds that return errors (e.g. byes or
1237
+ * rounds that haven't been played yet). Finals rounds (>= 24) use the finals URL.
1238
+ *
1239
+ * @param season - Season year.
1240
+ * @param competition - "AFLM" or "AFLW".
1241
+ * @returns Combined array of coaches votes for the season.
1242
+ */
1243
+ async fetchSeasonVotes(season, competition) {
1244
+ const allVotes = [];
1245
+ const maxRound = 30;
1246
+ for (let round = 1; round <= maxRound; round++) {
1247
+ const isFinals = round >= 24 && season >= 2018;
1248
+ const result = await this.scrapeRoundVotes(season, round, competition, isFinals);
1249
+ if (result.success && result.data.length > 0) {
1250
+ allVotes.push(...result.data);
1251
+ }
1252
+ }
1253
+ if (allVotes.length === 0) {
1254
+ return err(new ScrapeError(`No coaches votes found for season ${season}`, "afl-coaches"));
1255
+ }
1256
+ return ok(allVotes);
1257
+ }
1258
+ };
1259
+ }
1260
+ });
1261
+
1262
+ // src/api/coaches-votes.ts
1263
+ async function fetchCoachesVotes(query) {
1264
+ const competition = query.competition ?? "AFLM";
1265
+ if (query.season < 2006) {
1266
+ return err(new ScrapeError("No coaches votes data available before 2006", "afl-coaches"));
1267
+ }
1268
+ if (competition === "AFLW" && query.season < 2018) {
1269
+ return err(new ScrapeError("No AFLW coaches votes data available before 2018", "afl-coaches"));
1270
+ }
1271
+ const client = new AflCoachesClient();
1272
+ let result;
1273
+ if (query.round != null) {
1274
+ const isFinals = query.round >= 24 && query.season >= 2018;
1275
+ result = await client.scrapeRoundVotes(query.season, query.round, competition, isFinals);
1276
+ } else {
1277
+ result = await client.fetchSeasonVotes(query.season, competition);
1278
+ }
1279
+ if (!result.success) {
1280
+ return result;
1281
+ }
1282
+ let votes = result.data;
1283
+ if (query.team != null) {
1284
+ const normalisedTeam = normaliseTeamName(query.team);
1285
+ votes = votes.filter(
1286
+ (v) => normaliseTeamName(v.homeTeam) === normalisedTeam || normaliseTeamName(v.awayTeam) === normalisedTeam
1287
+ );
1288
+ if (votes.length === 0) {
1289
+ return ok([]);
1290
+ }
1291
+ }
1292
+ return ok(votes);
1293
+ }
1294
+ var init_coaches_votes = __esm({
1295
+ "src/api/coaches-votes.ts"() {
1296
+ "use strict";
1297
+ init_errors();
1298
+ init_result();
1299
+ init_team_mapping();
1300
+ init_afl_coaches();
1301
+ }
1302
+ });
1303
+
1304
+ // src/lib/validation.ts
1305
+ import { z } from "zod/v4";
1306
+ var AflApiTokenSchema, CompetitionSchema, CompetitionListSchema, CompseasonSchema, CompseasonListSchema, RoundSchema, RoundListSchema, ScoreSchema, PeriodScoreSchema, TeamScoreSchema, CfsMatchTeamSchema, CfsMatchSchema, CfsScoreSchema, CfsVenueSchema, MatchItemSchema, MatchItemListSchema, CfsPlayerInnerSchema, statNum, PlayerGameStatsSchema, PlayerStatsItemSchema, PlayerStatsListSchema, RosterPlayerSchema, TeamPlayersSchema, MatchRosterSchema, TeamItemSchema, TeamListSchema, SquadPlayerInnerSchema, SquadPlayerItemSchema, SquadSchema, SquadListSchema, WinLossRecordSchema, LadderEntryRawSchema, LadderResponseSchema;
1307
+ var init_validation = __esm({
1308
+ "src/lib/validation.ts"() {
1309
+ "use strict";
1310
+ AflApiTokenSchema = z.object({
1311
+ token: z.string(),
1312
+ disclaimer: z.string().optional()
1313
+ }).passthrough();
1314
+ CompetitionSchema = z.object({
1315
+ id: z.number(),
1316
+ name: z.string(),
1317
+ code: z.string().optional()
1318
+ }).passthrough();
1319
+ CompetitionListSchema = z.object({
1320
+ competitions: z.array(CompetitionSchema)
1321
+ }).passthrough();
1322
+ CompseasonSchema = z.object({
1323
+ id: z.number(),
1324
+ name: z.string(),
1325
+ shortName: z.string().optional(),
1326
+ currentRoundNumber: z.number().optional()
1327
+ }).passthrough();
1328
+ CompseasonListSchema = z.object({
1329
+ compSeasons: z.array(CompseasonSchema)
1330
+ }).passthrough();
1331
+ RoundSchema = z.object({
1332
+ id: z.number(),
1333
+ /** Provider ID used by /cfs/ endpoints (e.g. "CD_R202501401"). */
1334
+ providerId: z.string().optional(),
1335
+ name: z.string(),
1336
+ abbreviation: z.string().optional(),
1337
+ roundNumber: z.number(),
1338
+ utcStartTime: z.string().optional(),
1339
+ utcEndTime: z.string().optional()
1340
+ }).passthrough();
1341
+ RoundListSchema = z.object({
1342
+ rounds: z.array(RoundSchema)
1343
+ }).passthrough();
1344
+ ScoreSchema = z.object({
1345
+ totalScore: z.number(),
1346
+ goals: z.number(),
1347
+ behinds: z.number(),
1348
+ superGoals: z.number().nullable().optional()
1349
+ }).passthrough();
1350
+ PeriodScoreSchema = z.object({
1351
+ periodNumber: z.number(),
1352
+ score: ScoreSchema
1353
+ }).passthrough();
1354
+ TeamScoreSchema = z.object({
1355
+ matchScore: ScoreSchema,
1356
+ periodScore: z.array(PeriodScoreSchema).optional(),
1357
+ rushedBehinds: z.number().optional(),
1358
+ minutesInFront: z.number().optional()
1359
+ }).passthrough();
1360
+ CfsMatchTeamSchema = z.object({
1361
+ name: z.string(),
1362
+ teamId: z.string(),
1363
+ abbr: z.string().optional(),
1364
+ nickname: z.string().optional()
1365
+ }).passthrough();
1366
+ CfsMatchSchema = z.object({
1367
+ matchId: z.string(),
1368
+ name: z.string().optional(),
1369
+ status: z.string(),
1370
+ utcStartTime: z.string(),
1371
+ homeTeamId: z.string(),
1372
+ awayTeamId: z.string(),
1373
+ homeTeam: CfsMatchTeamSchema,
1374
+ awayTeam: CfsMatchTeamSchema,
1375
+ round: z.string().optional(),
1376
+ abbr: z.string().optional()
1377
+ }).passthrough();
1378
+ CfsScoreSchema = z.object({
1379
+ status: z.string(),
1380
+ matchId: z.string(),
1381
+ homeTeamScore: TeamScoreSchema,
1382
+ awayTeamScore: TeamScoreSchema
1383
+ }).passthrough();
1384
+ CfsVenueSchema = z.object({
1385
+ name: z.string(),
1386
+ venueId: z.string().optional(),
1387
+ state: z.string().optional(),
1388
+ timeZone: z.string().optional()
1389
+ }).passthrough();
1390
+ MatchItemSchema = z.object({
1391
+ match: CfsMatchSchema,
1392
+ score: CfsScoreSchema.nullish(),
1393
+ venue: CfsVenueSchema.optional(),
1394
+ round: z.object({
1395
+ name: z.string(),
1396
+ roundId: z.string(),
1397
+ roundNumber: z.number()
1398
+ }).passthrough().optional()
1399
+ }).passthrough();
1400
+ MatchItemListSchema = z.object({
1401
+ roundId: z.string().optional(),
1402
+ items: z.array(MatchItemSchema)
1403
+ }).passthrough();
1404
+ CfsPlayerInnerSchema = z.object({
1405
+ playerId: z.string(),
1406
+ playerName: z.object({
1407
+ givenName: z.string(),
1408
+ surname: z.string()
1409
+ }).passthrough(),
1410
+ captain: z.boolean().optional(),
1411
+ playerJumperNumber: z.number().optional()
1412
+ }).passthrough();
1413
+ statNum = z.number().nullable().optional();
1414
+ PlayerGameStatsSchema = z.object({
1415
+ goals: statNum,
1416
+ behinds: statNum,
1417
+ kicks: statNum,
1418
+ handballs: statNum,
1419
+ disposals: statNum,
1420
+ marks: statNum,
1421
+ bounces: statNum,
1422
+ tackles: statNum,
1423
+ contestedPossessions: statNum,
1424
+ uncontestedPossessions: statNum,
1425
+ totalPossessions: statNum,
1426
+ inside50s: statNum,
1427
+ marksInside50: statNum,
1428
+ contestedMarks: statNum,
1429
+ hitouts: statNum,
1430
+ onePercenters: statNum,
1431
+ disposalEfficiency: statNum,
1432
+ clangers: statNum,
1433
+ freesFor: statNum,
1434
+ freesAgainst: statNum,
1435
+ dreamTeamPoints: statNum,
1436
+ clearances: z.object({
1437
+ centreClearances: statNum,
1438
+ stoppageClearances: statNum,
1439
+ totalClearances: statNum
1440
+ }).passthrough().nullable().optional(),
1441
+ rebound50s: statNum,
1442
+ goalAssists: statNum,
1443
+ goalAccuracy: statNum,
1444
+ turnovers: statNum,
1445
+ intercepts: statNum,
1446
+ tacklesInside50: statNum,
1447
+ shotsAtGoal: statNum,
1448
+ metresGained: statNum,
1449
+ scoreInvolvements: statNum,
1450
+ ratingPoints: statNum,
1451
+ extendedStats: z.object({
1452
+ effectiveDisposals: statNum,
1453
+ effectiveKicks: statNum,
1454
+ kickEfficiency: statNum,
1455
+ kickToHandballRatio: statNum,
1456
+ pressureActs: statNum,
1457
+ defHalfPressureActs: statNum,
1458
+ spoils: statNum,
1459
+ hitoutsToAdvantage: statNum,
1460
+ hitoutWinPercentage: statNum,
1461
+ hitoutToAdvantageRate: statNum,
1462
+ groundBallGets: statNum,
1463
+ f50GroundBallGets: statNum,
1464
+ interceptMarks: statNum,
1465
+ marksOnLead: statNum,
1466
+ contestedPossessionRate: statNum,
1467
+ contestOffOneOnOnes: statNum,
1468
+ contestOffWins: statNum,
1469
+ contestOffWinsPercentage: statNum,
1470
+ contestDefOneOnOnes: statNum,
1471
+ contestDefLosses: statNum,
1472
+ contestDefLossPercentage: statNum,
1473
+ centreBounceAttendances: statNum,
1474
+ kickins: statNum,
1475
+ kickinsPlayon: statNum,
1476
+ ruckContests: statNum,
1477
+ scoreLaunches: statNum
1478
+ }).passthrough().nullable().optional()
1479
+ }).passthrough();
1480
+ PlayerStatsItemSchema = z.object({
1481
+ player: z.object({
1482
+ player: z.object({
1483
+ position: z.string().optional(),
1484
+ player: CfsPlayerInnerSchema
1485
+ }).passthrough(),
1486
+ jumperNumber: z.number().optional()
1487
+ }).passthrough(),
1488
+ teamId: z.string(),
1489
+ playerStats: z.object({
1490
+ stats: PlayerGameStatsSchema,
1491
+ timeOnGroundPercentage: z.number().nullable().optional()
1492
+ }).passthrough()
1493
+ }).passthrough();
1494
+ PlayerStatsListSchema = z.object({
1495
+ homeTeamPlayerStats: z.array(PlayerStatsItemSchema),
1496
+ awayTeamPlayerStats: z.array(PlayerStatsItemSchema)
1497
+ }).passthrough();
1498
+ RosterPlayerSchema = z.object({
1499
+ player: z.object({
1500
+ position: z.string().optional(),
1501
+ player: CfsPlayerInnerSchema
1502
+ }).passthrough(),
1503
+ jumperNumber: z.number().optional()
1504
+ }).passthrough();
1505
+ TeamPlayersSchema = z.object({
1506
+ teamId: z.string(),
1507
+ players: z.array(RosterPlayerSchema)
1508
+ }).passthrough();
1509
+ MatchRosterSchema = z.object({
1510
+ match: CfsMatchSchema,
1511
+ teamPlayers: z.array(TeamPlayersSchema)
1512
+ }).passthrough();
1513
+ TeamItemSchema = z.object({
1514
+ id: z.number(),
1515
+ name: z.string(),
1516
+ abbreviation: z.string().optional(),
1517
+ teamType: z.string().optional()
1518
+ }).passthrough();
1519
+ TeamListSchema = z.object({
1520
+ teams: z.array(TeamItemSchema)
1521
+ }).passthrough();
1522
+ SquadPlayerInnerSchema = z.object({
1523
+ id: z.number(),
1524
+ providerId: z.string().optional(),
1525
+ firstName: z.string(),
1526
+ surname: z.string(),
1527
+ dateOfBirth: z.string().optional(),
1528
+ heightInCm: z.number().optional(),
1529
+ weightInKg: z.number().optional(),
1530
+ draftYear: z.string().optional(),
1531
+ draftPosition: z.string().optional(),
1532
+ draftType: z.string().optional(),
1533
+ debutYear: z.string().optional(),
1534
+ recruitedFrom: z.string().optional()
1535
+ }).passthrough();
1536
+ SquadPlayerItemSchema = z.object({
354
1537
  player: SquadPlayerInnerSchema,
355
1538
  jumperNumber: z.number().optional(),
356
1539
  position: z.string().optional()
@@ -761,90 +1944,227 @@ var init_afl_api = __esm({
761
1944
  }
762
1945
  });
763
1946
 
764
- // src/transforms/match-results.ts
765
- function inferRoundType(roundName) {
766
- return FINALS_PATTERN.test(roundName) ? "Finals" : "HomeAndAway";
767
- }
768
- function toMatchStatus(raw) {
769
- switch (raw) {
770
- case "CONCLUDED":
771
- case "COMPLETE":
772
- return "Complete";
773
- case "LIVE":
774
- case "IN_PROGRESS":
775
- return "Live";
776
- case "UPCOMING":
777
- case "SCHEDULED":
778
- return "Upcoming";
779
- case "POSTPONED":
780
- return "Postponed";
781
- case "CANCELLED":
782
- return "Cancelled";
783
- default:
784
- return "Complete";
1947
+ // src/lib/squiggle-validation.ts
1948
+ import { z as z2 } from "zod";
1949
+ var SquiggleGameSchema, SquiggleGamesResponseSchema, SquiggleStandingSchema, SquiggleStandingsResponseSchema;
1950
+ var init_squiggle_validation = __esm({
1951
+ "src/lib/squiggle-validation.ts"() {
1952
+ "use strict";
1953
+ SquiggleGameSchema = z2.object({
1954
+ id: z2.number(),
1955
+ year: z2.number(),
1956
+ round: z2.number(),
1957
+ roundname: z2.string(),
1958
+ hteam: z2.string(),
1959
+ ateam: z2.string(),
1960
+ hteamid: z2.number(),
1961
+ ateamid: z2.number(),
1962
+ hscore: z2.number().nullable(),
1963
+ ascore: z2.number().nullable(),
1964
+ hgoals: z2.number().nullable(),
1965
+ agoals: z2.number().nullable(),
1966
+ hbehinds: z2.number().nullable(),
1967
+ abehinds: z2.number().nullable(),
1968
+ winner: z2.string().nullable(),
1969
+ winnerteamid: z2.number().nullable(),
1970
+ venue: z2.string(),
1971
+ date: z2.string(),
1972
+ localtime: z2.string(),
1973
+ tz: z2.string(),
1974
+ unixtime: z2.number(),
1975
+ timestr: z2.string().nullable(),
1976
+ complete: z2.number(),
1977
+ is_final: z2.number(),
1978
+ is_grand_final: z2.number(),
1979
+ updated: z2.string()
1980
+ });
1981
+ SquiggleGamesResponseSchema = z2.object({
1982
+ games: z2.array(SquiggleGameSchema)
1983
+ });
1984
+ SquiggleStandingSchema = z2.object({
1985
+ id: z2.number(),
1986
+ name: z2.string(),
1987
+ rank: z2.number(),
1988
+ played: z2.number(),
1989
+ wins: z2.number(),
1990
+ losses: z2.number(),
1991
+ draws: z2.number(),
1992
+ pts: z2.number(),
1993
+ for: z2.number(),
1994
+ against: z2.number(),
1995
+ percentage: z2.number(),
1996
+ goals_for: z2.number(),
1997
+ goals_against: z2.number(),
1998
+ behinds_for: z2.number(),
1999
+ behinds_against: z2.number()
2000
+ });
2001
+ SquiggleStandingsResponseSchema = z2.object({
2002
+ standings: z2.array(SquiggleStandingSchema)
2003
+ });
785
2004
  }
786
- }
787
- function toQuarterScore(period) {
788
- return {
789
- goals: period.score.goals,
790
- behinds: period.score.behinds,
791
- points: period.score.totalScore
792
- };
793
- }
794
- function findPeriod(periods, quarter) {
795
- if (!periods) return null;
796
- const period = periods.find((p) => p.periodNumber === quarter);
797
- return period ? toQuarterScore(period) : null;
798
- }
799
- function transformMatchItems(items, season, competition, source = "afl-api") {
800
- return items.map((item) => {
801
- const homeScore = item.score?.homeTeamScore;
802
- const awayScore = item.score?.awayTeamScore;
803
- const homePoints = homeScore?.matchScore.totalScore ?? 0;
804
- const awayPoints = awayScore?.matchScore.totalScore ?? 0;
805
- return {
806
- matchId: item.match.matchId,
807
- season,
808
- roundNumber: item.round?.roundNumber ?? 0,
809
- roundType: inferRoundType(item.round?.name ?? ""),
810
- date: new Date(item.match.utcStartTime),
811
- venue: item.venue?.name ?? "",
812
- homeTeam: normaliseTeamName(item.match.homeTeam.name),
813
- awayTeam: normaliseTeamName(item.match.awayTeam.name),
814
- homeGoals: homeScore?.matchScore.goals ?? 0,
815
- homeBehinds: homeScore?.matchScore.behinds ?? 0,
816
- homePoints,
817
- awayGoals: awayScore?.matchScore.goals ?? 0,
818
- awayBehinds: awayScore?.matchScore.behinds ?? 0,
819
- awayPoints,
820
- margin: homePoints - awayPoints,
821
- q1Home: findPeriod(homeScore?.periodScore, 1),
822
- q2Home: findPeriod(homeScore?.periodScore, 2),
823
- q3Home: findPeriod(homeScore?.periodScore, 3),
824
- q4Home: findPeriod(homeScore?.periodScore, 4),
825
- q1Away: findPeriod(awayScore?.periodScore, 1),
826
- q2Away: findPeriod(awayScore?.periodScore, 2),
827
- q3Away: findPeriod(awayScore?.periodScore, 3),
828
- q4Away: findPeriod(awayScore?.periodScore, 4),
829
- status: toMatchStatus(item.match.status),
830
- attendance: null,
831
- venueState: item.venue?.state ?? null,
832
- venueTimezone: item.venue?.timeZone ?? null,
833
- homeRushedBehinds: homeScore?.rushedBehinds ?? null,
834
- awayRushedBehinds: awayScore?.rushedBehinds ?? null,
835
- homeMinutesInFront: homeScore?.minutesInFront ?? null,
836
- awayMinutesInFront: awayScore?.minutesInFront ?? null,
837
- source,
838
- competition
2005
+ });
2006
+
2007
+ // src/sources/squiggle.ts
2008
+ var SQUIGGLE_BASE, USER_AGENT, SquiggleClient;
2009
+ var init_squiggle = __esm({
2010
+ "src/sources/squiggle.ts"() {
2011
+ "use strict";
2012
+ init_errors();
2013
+ init_result();
2014
+ init_squiggle_validation();
2015
+ SQUIGGLE_BASE = "https://api.squiggle.com.au/";
2016
+ USER_AGENT = "fitzRoy-ts/1.0 (https://github.com/jackemcpherson/fitzRoy-ts)";
2017
+ SquiggleClient = class {
2018
+ fetchFn;
2019
+ constructor(options) {
2020
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch;
2021
+ }
2022
+ /**
2023
+ * Fetch JSON from the Squiggle API.
2024
+ */
2025
+ async fetchJson(params) {
2026
+ const url = `${SQUIGGLE_BASE}?${params.toString()}`;
2027
+ try {
2028
+ const response = await this.fetchFn(url, {
2029
+ headers: { "User-Agent": USER_AGENT }
2030
+ });
2031
+ if (!response.ok) {
2032
+ return err(
2033
+ new ScrapeError(`Squiggle request failed: ${response.status} (${url})`, "squiggle")
2034
+ );
2035
+ }
2036
+ const json = await response.json();
2037
+ return ok(json);
2038
+ } catch (cause) {
2039
+ return err(
2040
+ new ScrapeError(
2041
+ `Squiggle request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2042
+ "squiggle"
2043
+ )
2044
+ );
2045
+ }
2046
+ }
2047
+ /**
2048
+ * Fetch games (match results or fixture) from the Squiggle API.
2049
+ *
2050
+ * @param year - Season year.
2051
+ * @param round - Optional round number.
2052
+ * @param complete - Optional completion filter (100 = complete, omit for all).
2053
+ */
2054
+ async fetchGames(year, round, complete) {
2055
+ const params = new URLSearchParams({ q: "games", year: String(year) });
2056
+ if (round != null) params.set("round", String(round));
2057
+ if (complete != null) params.set("complete", String(complete));
2058
+ const result = await this.fetchJson(params);
2059
+ if (!result.success) return result;
2060
+ const parsed = SquiggleGamesResponseSchema.safeParse(result.data);
2061
+ if (!parsed.success) {
2062
+ return err(
2063
+ new ScrapeError(`Invalid Squiggle games response: ${parsed.error.message}`, "squiggle")
2064
+ );
2065
+ }
2066
+ return ok(parsed.data);
2067
+ }
2068
+ /**
2069
+ * Fetch standings (ladder) from the Squiggle API.
2070
+ *
2071
+ * @param year - Season year.
2072
+ * @param round - Optional round number.
2073
+ */
2074
+ async fetchStandings(year, round) {
2075
+ const params = new URLSearchParams({ q: "standings", year: String(year) });
2076
+ if (round != null) params.set("round", String(round));
2077
+ const result = await this.fetchJson(params);
2078
+ if (!result.success) return result;
2079
+ const parsed = SquiggleStandingsResponseSchema.safeParse(result.data);
2080
+ if (!parsed.success) {
2081
+ return err(
2082
+ new ScrapeError(`Invalid Squiggle standings response: ${parsed.error.message}`, "squiggle")
2083
+ );
2084
+ }
2085
+ return ok(parsed.data);
2086
+ }
839
2087
  };
840
- });
2088
+ }
2089
+ });
2090
+
2091
+ // src/transforms/squiggle.ts
2092
+ function toMatchStatus2(complete) {
2093
+ if (complete === 100) return "Complete";
2094
+ if (complete > 0) return "Live";
2095
+ return "Upcoming";
841
2096
  }
842
- var FINALS_PATTERN;
843
- var init_match_results = __esm({
844
- "src/transforms/match-results.ts"() {
2097
+ function transformSquiggleGamesToResults(games, season) {
2098
+ return games.filter((g) => g.complete === 100).map((g) => ({
2099
+ matchId: `SQ_${g.id}`,
2100
+ season,
2101
+ roundNumber: g.round,
2102
+ roundType: inferRoundType(g.roundname),
2103
+ date: new Date(g.unixtime * 1e3),
2104
+ venue: g.venue,
2105
+ homeTeam: normaliseTeamName(g.hteam),
2106
+ awayTeam: normaliseTeamName(g.ateam),
2107
+ homeGoals: g.hgoals ?? 0,
2108
+ homeBehinds: g.hbehinds ?? 0,
2109
+ homePoints: g.hscore ?? 0,
2110
+ awayGoals: g.agoals ?? 0,
2111
+ awayBehinds: g.abehinds ?? 0,
2112
+ awayPoints: g.ascore ?? 0,
2113
+ margin: (g.hscore ?? 0) - (g.ascore ?? 0),
2114
+ q1Home: null,
2115
+ q2Home: null,
2116
+ q3Home: null,
2117
+ q4Home: null,
2118
+ q1Away: null,
2119
+ q2Away: null,
2120
+ q3Away: null,
2121
+ q4Away: null,
2122
+ status: "Complete",
2123
+ attendance: null,
2124
+ venueState: null,
2125
+ venueTimezone: g.tz || null,
2126
+ homeRushedBehinds: null,
2127
+ awayRushedBehinds: null,
2128
+ homeMinutesInFront: null,
2129
+ awayMinutesInFront: null,
2130
+ source: "squiggle",
2131
+ competition: "AFLM"
2132
+ }));
2133
+ }
2134
+ function transformSquiggleGamesToFixture(games, season) {
2135
+ return games.map((g) => ({
2136
+ matchId: `SQ_${g.id}`,
2137
+ season,
2138
+ roundNumber: g.round,
2139
+ roundType: inferRoundType(g.roundname),
2140
+ date: new Date(g.unixtime * 1e3),
2141
+ venue: g.venue,
2142
+ homeTeam: normaliseTeamName(g.hteam),
2143
+ awayTeam: normaliseTeamName(g.ateam),
2144
+ status: toMatchStatus2(g.complete),
2145
+ competition: "AFLM"
2146
+ }));
2147
+ }
2148
+ function transformSquiggleStandings(standings) {
2149
+ return standings.map((s) => ({
2150
+ position: s.rank,
2151
+ team: normaliseTeamName(s.name),
2152
+ played: s.played,
2153
+ wins: s.wins,
2154
+ losses: s.losses,
2155
+ draws: s.draws,
2156
+ pointsFor: s.for,
2157
+ pointsAgainst: s.against,
2158
+ percentage: s.percentage,
2159
+ premiershipsPoints: s.pts,
2160
+ form: null
2161
+ }));
2162
+ }
2163
+ var init_squiggle2 = __esm({
2164
+ "src/transforms/squiggle.ts"() {
845
2165
  "use strict";
846
2166
  init_team_mapping();
847
- FINALS_PATTERN = /final|elimination|qualifying|preliminary|semi|grand/i;
2167
+ init_match_results();
848
2168
  }
849
2169
  });
850
2170
 
@@ -865,312 +2185,198 @@ function toFixture(item, season, fallbackRoundNumber, competition) {
865
2185
  }
866
2186
  async function fetchFixture(query) {
867
2187
  const competition = query.competition ?? "AFLM";
868
- if (query.source !== "afl-api") {
869
- return err(
870
- new UnsupportedSourceError(
871
- "Fixture data is only available from the AFL API source.",
872
- query.source
873
- )
874
- );
875
- }
876
- const client = new AflApiClient();
877
- const seasonResult = await client.resolveCompSeason(competition, query.season);
878
- if (!seasonResult.success) return seasonResult;
879
- if (query.round != null) {
880
- const itemsResult = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
881
- if (!itemsResult.success) return itemsResult;
882
- return ok(itemsResult.data.map((item) => toFixture(item, query.season, 0, competition)));
883
- }
884
- const roundsResult = await client.resolveRounds(seasonResult.data);
885
- if (!roundsResult.success) return roundsResult;
886
- const roundProviderIds = roundsResult.data.flatMap(
887
- (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
888
- );
889
- const roundResults = await Promise.all(
890
- roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
891
- );
892
- const fixtures = [];
893
- for (let i = 0; i < roundResults.length; i++) {
894
- const result = roundResults[i];
895
- if (!result?.success) continue;
896
- const roundNumber = roundProviderIds[i]?.roundNumber ?? 0;
897
- for (const item of result.data) {
898
- fixtures.push(toFixture(item, query.season, roundNumber, competition));
899
- }
900
- }
901
- return ok(fixtures);
902
- }
903
- var init_fixture = __esm({
904
- "src/api/fixture.ts"() {
905
- "use strict";
906
- init_errors();
907
- init_result();
908
- init_team_mapping();
909
- init_afl_api();
910
- init_match_results();
911
- }
912
- });
913
-
914
- // src/transforms/ladder.ts
915
- function transformLadderEntries(entries) {
916
- return entries.map((entry) => {
917
- const record = entry.thisSeasonRecord;
918
- const wl = record?.winLossRecord;
919
- return {
920
- position: entry.position,
921
- team: normaliseTeamName(entry.team.name),
922
- played: entry.played ?? wl?.played ?? 0,
923
- wins: wl?.wins ?? 0,
924
- losses: wl?.losses ?? 0,
925
- draws: wl?.draws ?? 0,
926
- pointsFor: entry.pointsFor ?? 0,
927
- pointsAgainst: entry.pointsAgainst ?? 0,
928
- percentage: record?.percentage ?? 0,
929
- premiershipsPoints: record?.aggregatePoints ?? 0,
930
- form: entry.form ?? null
931
- };
932
- });
933
- }
934
- var init_ladder = __esm({
935
- "src/transforms/ladder.ts"() {
936
- "use strict";
937
- init_team_mapping();
938
- }
939
- });
940
-
941
- // src/api/ladder.ts
942
- async function fetchLadder(query) {
943
- const competition = query.competition ?? "AFLM";
944
- if (query.source !== "afl-api") {
945
- return err(
946
- new UnsupportedSourceError(
947
- "Ladder data is only available from the AFL API source.",
948
- query.source
949
- )
950
- );
2188
+ if (query.source === "squiggle") {
2189
+ const client2 = new SquiggleClient();
2190
+ const result = await client2.fetchGames(query.season, query.round ?? void 0);
2191
+ if (!result.success) return result;
2192
+ return ok(transformSquiggleGamesToFixture(result.data.games, query.season));
951
2193
  }
952
- const client = new AflApiClient();
953
- const seasonResult = await client.resolveCompSeason(competition, query.season);
954
- if (!seasonResult.success) return seasonResult;
955
- let roundId;
956
- if (query.round != null) {
957
- const roundsResult = await client.resolveRounds(seasonResult.data);
958
- if (!roundsResult.success) return roundsResult;
959
- const round = roundsResult.data.find((r) => r.roundNumber === query.round);
960
- if (round) {
961
- roundId = round.id;
2194
+ if (query.source === "footywire") {
2195
+ const fwClient = new FootyWireClient();
2196
+ const result = await fwClient.fetchSeasonFixture(query.season);
2197
+ if (!result.success) return result;
2198
+ if (query.round != null) {
2199
+ return ok(result.data.filter((f) => f.roundNumber === query.round));
962
2200
  }
2201
+ return result;
963
2202
  }
964
- const ladderResult = await client.fetchLadder(seasonResult.data, roundId);
965
- if (!ladderResult.success) return ladderResult;
966
- const firstLadder = ladderResult.data.ladders[0];
967
- const entries = firstLadder ? transformLadderEntries(firstLadder.entries) : [];
968
- return ok({
969
- season: query.season,
970
- roundNumber: ladderResult.data.round?.roundNumber ?? null,
971
- entries,
972
- competition
973
- });
974
- }
975
- var init_ladder2 = __esm({
976
- "src/api/ladder.ts"() {
977
- "use strict";
978
- init_errors();
979
- init_result();
980
- init_afl_api();
981
- init_ladder();
982
- }
983
- });
984
-
985
- // src/transforms/lineup.ts
986
- function transformMatchRoster(roster, season, roundNumber, competition) {
987
- const homeTeamId = roster.match.homeTeamId;
988
- const awayTeamId = roster.match.awayTeamId;
989
- const homeTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === homeTeamId);
990
- const awayTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === awayTeamId);
991
- const mapPlayers = (players) => players.map((p) => {
992
- const inner = p.player.player;
993
- const position = p.player.position ?? null;
994
- return {
995
- playerId: inner.playerId,
996
- givenName: inner.playerName.givenName,
997
- surname: inner.playerName.surname,
998
- displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
999
- jumperNumber: p.jumperNumber ?? null,
1000
- position,
1001
- isEmergency: position !== null && EMERGENCY_POSITIONS.has(position),
1002
- isSubstitute: position !== null && SUBSTITUTE_POSITIONS.has(position)
1003
- };
1004
- });
1005
- return {
1006
- matchId: roster.match.matchId,
1007
- season,
1008
- roundNumber,
1009
- homeTeam: normaliseTeamName(roster.match.homeTeam.name),
1010
- awayTeam: normaliseTeamName(roster.match.awayTeam.name),
1011
- homePlayers: homeTeamPlayers ? mapPlayers(homeTeamPlayers.players) : [],
1012
- awayPlayers: awayTeamPlayers ? mapPlayers(awayTeamPlayers.players) : [],
1013
- competition
1014
- };
1015
- }
1016
- var EMERGENCY_POSITIONS, SUBSTITUTE_POSITIONS;
1017
- var init_lineup = __esm({
1018
- "src/transforms/lineup.ts"() {
1019
- "use strict";
1020
- init_team_mapping();
1021
- EMERGENCY_POSITIONS = /* @__PURE__ */ new Set(["EMG", "EMERG"]);
1022
- SUBSTITUTE_POSITIONS = /* @__PURE__ */ new Set(["SUB", "INT"]);
1023
- }
1024
- });
1025
-
1026
- // src/api/lineup.ts
1027
- async function fetchLineup(query) {
1028
- const competition = query.competition ?? "AFLM";
1029
2203
  if (query.source !== "afl-api") {
1030
2204
  return err(
1031
2205
  new UnsupportedSourceError(
1032
- "Lineup data is only available from the AFL API source.",
2206
+ "Fixture data is only available from the AFL API, FootyWire, or Squiggle sources.",
1033
2207
  query.source
1034
2208
  )
1035
- );
1036
- }
1037
- const client = new AflApiClient();
1038
- if (query.matchId) {
1039
- const rosterResult = await client.fetchMatchRoster(query.matchId);
1040
- if (!rosterResult.success) return rosterResult;
1041
- return ok([transformMatchRoster(rosterResult.data, query.season, query.round, competition)]);
1042
- }
1043
- const seasonResult = await client.resolveCompSeason(competition, query.season);
1044
- if (!seasonResult.success) return seasonResult;
1045
- const matchItems = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
1046
- if (!matchItems.success) return matchItems;
1047
- if (matchItems.data.length === 0) {
1048
- return err(new AflApiError(`No matches found for round ${query.round}`));
1049
- }
1050
- const rosterResults = await Promise.all(
1051
- matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
1052
- );
1053
- const lineups = [];
1054
- for (const rosterResult of rosterResults) {
1055
- if (!rosterResult.success) return rosterResult;
1056
- lineups.push(transformMatchRoster(rosterResult.data, query.season, query.round, competition));
1057
- }
1058
- return ok(lineups);
1059
- }
1060
- var init_lineup2 = __esm({
1061
- "src/api/lineup.ts"() {
1062
- "use strict";
1063
- init_errors();
1064
- init_result();
1065
- init_afl_api();
1066
- init_lineup();
1067
- }
1068
- });
1069
-
1070
- // src/lib/date-utils.ts
1071
- function parseFootyWireDate(dateStr) {
1072
- const trimmed = dateStr.trim();
1073
- if (trimmed === "") {
1074
- return null;
1075
- }
1076
- const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
1077
- const normalised = withoutDow.replace(/-/g, " ");
1078
- const match = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
1079
- if (!match) {
1080
- return null;
1081
- }
1082
- const [, dayStr, monthStr, yearStr] = match;
1083
- if (!dayStr || !monthStr || !yearStr) {
1084
- return null;
1085
- }
1086
- const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
1087
- if (monthIndex === void 0) {
1088
- return null;
1089
- }
1090
- const year = Number.parseInt(yearStr, 10);
1091
- const day = Number.parseInt(dayStr, 10);
1092
- const date = new Date(Date.UTC(year, monthIndex, day));
1093
- if (Number.isNaN(date.getTime())) {
1094
- return null;
1095
- }
1096
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
1097
- return null;
1098
- }
1099
- return date;
1100
- }
1101
- function parseAflTablesDate(dateStr) {
1102
- const trimmed = dateStr.trim();
1103
- if (trimmed === "") {
1104
- return null;
1105
- }
1106
- const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
1107
- const normalised = withoutDow.replace(/[-/]/g, " ");
1108
- const dmy = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
1109
- if (dmy) {
1110
- const [, dayStr, monthStr, yearStr] = dmy;
1111
- if (dayStr && monthStr && yearStr) {
1112
- return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
1113
- }
2209
+ );
1114
2210
  }
1115
- const mdy = /^([A-Za-z]+)\s+(\d{1,2})\s+(\d{4})$/.exec(normalised);
1116
- if (mdy) {
1117
- const [, monthStr, dayStr, yearStr] = mdy;
1118
- if (dayStr && monthStr && yearStr) {
1119
- return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
2211
+ const client = new AflApiClient();
2212
+ const seasonResult = await client.resolveCompSeason(competition, query.season);
2213
+ if (!seasonResult.success) return seasonResult;
2214
+ if (query.round != null) {
2215
+ const itemsResult = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
2216
+ if (!itemsResult.success) return itemsResult;
2217
+ return ok(itemsResult.data.map((item) => toFixture(item, query.season, 0, competition)));
2218
+ }
2219
+ const roundsResult = await client.resolveRounds(seasonResult.data);
2220
+ if (!roundsResult.success) return roundsResult;
2221
+ const roundProviderIds = roundsResult.data.flatMap(
2222
+ (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
2223
+ );
2224
+ const roundResults = await Promise.all(
2225
+ roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
2226
+ );
2227
+ const fixtures = [];
2228
+ for (let i = 0; i < roundResults.length; i++) {
2229
+ const result = roundResults[i];
2230
+ if (!result?.success) continue;
2231
+ const roundNumber = roundProviderIds[i]?.roundNumber ?? 0;
2232
+ for (const item of result.data) {
2233
+ fixtures.push(toFixture(item, query.season, roundNumber, competition));
1120
2234
  }
1121
2235
  }
1122
- return null;
2236
+ return ok(fixtures);
1123
2237
  }
1124
- function buildUtcDate(year, monthStr, day) {
1125
- const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
1126
- if (monthIndex === void 0) {
1127
- return null;
1128
- }
1129
- const date = new Date(Date.UTC(year, monthIndex, day));
1130
- if (Number.isNaN(date.getTime())) {
1131
- return null;
1132
- }
1133
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
1134
- return null;
2238
+ var init_fixture = __esm({
2239
+ "src/api/fixture.ts"() {
2240
+ "use strict";
2241
+ init_errors();
2242
+ init_result();
2243
+ init_team_mapping();
2244
+ init_afl_api();
2245
+ init_footywire();
2246
+ init_squiggle();
2247
+ init_match_results();
2248
+ init_squiggle2();
1135
2249
  }
1136
- return date;
2250
+ });
2251
+
2252
+ // src/transforms/afl-tables-player-stats.ts
2253
+ import * as cheerio4 from "cheerio";
2254
+ function parseName(raw) {
2255
+ const cleaned = raw.replace(/[↑↓]/g, "").trim();
2256
+ const parts = cleaned.split(",").map((s) => s.trim());
2257
+ const surname = parts[0] ?? "";
2258
+ const givenName = parts[1] ?? "";
2259
+ return {
2260
+ givenName,
2261
+ surname,
2262
+ displayName: givenName ? `${givenName} ${surname}` : surname
2263
+ };
1137
2264
  }
1138
- var MONTH_ABBREV_TO_INDEX;
1139
- var init_date_utils = __esm({
1140
- "src/lib/date-utils.ts"() {
2265
+ function parseAflTablesGameStats(html, matchId, season, roundNumber) {
2266
+ const $ = cheerio4.load(html);
2267
+ const stats = [];
2268
+ $("table.sortable").each((_tableIdx, table) => {
2269
+ const headerText = $(table).find("thead tr").first().text().trim();
2270
+ const teamMatch = /^(\w[\w\s]+?)\s+Match Statistics/i.exec(headerText);
2271
+ if (!teamMatch) return;
2272
+ const teamName = normaliseTeamName(teamMatch[1]?.trim() ?? "");
2273
+ $(table).find("tbody tr").each((_rowIdx, row) => {
2274
+ const cells = $(row).find("td").map((_, c) => $(c).text().trim()).get();
2275
+ if (cells.length < 24) return;
2276
+ const jumperStr = cells[0] ?? "";
2277
+ const jumperNumber = safeInt(jumperStr.replace(/[↑↓]/g, ""));
2278
+ const { givenName, surname, displayName } = parseName(cells[1] ?? "");
2279
+ stats.push({
2280
+ matchId: `AT_${matchId}`,
2281
+ season,
2282
+ roundNumber,
2283
+ team: teamName,
2284
+ competition: "AFLM",
2285
+ playerId: `AT_${displayName.replace(/\s+/g, "_")}`,
2286
+ givenName,
2287
+ surname,
2288
+ displayName,
2289
+ jumperNumber,
2290
+ kicks: safeInt(cells[2] ?? ""),
2291
+ handballs: safeInt(cells[4] ?? ""),
2292
+ disposals: safeInt(cells[5] ?? ""),
2293
+ marks: safeInt(cells[3] ?? ""),
2294
+ goals: safeInt(cells[6] ?? ""),
2295
+ behinds: safeInt(cells[7] ?? ""),
2296
+ tackles: safeInt(cells[9] ?? ""),
2297
+ hitouts: safeInt(cells[8] ?? ""),
2298
+ freesFor: safeInt(cells[14] ?? ""),
2299
+ freesAgainst: safeInt(cells[15] ?? ""),
2300
+ contestedPossessions: safeInt(cells[17] ?? ""),
2301
+ uncontestedPossessions: safeInt(cells[18] ?? ""),
2302
+ contestedMarks: safeInt(cells[19] ?? ""),
2303
+ intercepts: null,
2304
+ centreClearances: null,
2305
+ stoppageClearances: null,
2306
+ totalClearances: safeInt(cells[12] ?? ""),
2307
+ inside50s: safeInt(cells[11] ?? ""),
2308
+ rebound50s: safeInt(cells[10] ?? ""),
2309
+ clangers: safeInt(cells[13] ?? ""),
2310
+ turnovers: null,
2311
+ onePercenters: safeInt(cells[21] ?? ""),
2312
+ bounces: safeInt(cells[22] ?? ""),
2313
+ goalAssists: safeInt(cells[23] ?? ""),
2314
+ disposalEfficiency: null,
2315
+ metresGained: null,
2316
+ goalAccuracy: null,
2317
+ marksInside50: safeInt(cells[20] ?? ""),
2318
+ tacklesInside50: null,
2319
+ shotsAtGoal: null,
2320
+ scoreInvolvements: null,
2321
+ totalPossessions: null,
2322
+ timeOnGroundPercentage: safeInt(cells[24] ?? ""),
2323
+ ratingPoints: null,
2324
+ dreamTeamPoints: null,
2325
+ effectiveDisposals: null,
2326
+ effectiveKicks: null,
2327
+ kickEfficiency: null,
2328
+ kickToHandballRatio: null,
2329
+ pressureActs: null,
2330
+ defHalfPressureActs: null,
2331
+ spoils: null,
2332
+ hitoutsToAdvantage: null,
2333
+ hitoutWinPercentage: null,
2334
+ hitoutToAdvantageRate: null,
2335
+ groundBallGets: null,
2336
+ f50GroundBallGets: null,
2337
+ interceptMarks: null,
2338
+ marksOnLead: null,
2339
+ contestedPossessionRate: null,
2340
+ contestOffOneOnOnes: null,
2341
+ contestOffWins: null,
2342
+ contestOffWinsPercentage: null,
2343
+ contestDefOneOnOnes: null,
2344
+ contestDefLosses: null,
2345
+ contestDefLossPercentage: null,
2346
+ centreBounceAttendances: null,
2347
+ kickins: null,
2348
+ kickinsPlayon: null,
2349
+ ruckContests: null,
2350
+ scoreLaunches: null,
2351
+ source: "afl-tables"
2352
+ });
2353
+ });
2354
+ });
2355
+ return stats;
2356
+ }
2357
+ function extractGameUrls(seasonHtml) {
2358
+ const $ = cheerio4.load(seasonHtml);
2359
+ const urls = [];
2360
+ $("tr:nth-child(2) td:nth-child(4) a").each((_i, el) => {
2361
+ const href = $(el).attr("href");
2362
+ if (href) {
2363
+ urls.push(href.replace("..", "https://afltables.com/afl"));
2364
+ }
2365
+ });
2366
+ return urls;
2367
+ }
2368
+ var init_afl_tables_player_stats = __esm({
2369
+ "src/transforms/afl-tables-player-stats.ts"() {
1141
2370
  "use strict";
1142
- MONTH_ABBREV_TO_INDEX = /* @__PURE__ */ new Map([
1143
- ["jan", 0],
1144
- ["feb", 1],
1145
- ["mar", 2],
1146
- ["apr", 3],
1147
- ["may", 4],
1148
- ["jun", 5],
1149
- ["jul", 6],
1150
- ["aug", 7],
1151
- ["sep", 8],
1152
- ["oct", 9],
1153
- ["nov", 10],
1154
- ["dec", 11],
1155
- ["january", 0],
1156
- ["february", 1],
1157
- ["march", 2],
1158
- ["april", 3],
1159
- ["june", 5],
1160
- ["july", 6],
1161
- ["august", 7],
1162
- ["september", 8],
1163
- ["october", 9],
1164
- ["november", 10],
1165
- ["december", 11]
1166
- ]);
2371
+ init_parse_utils();
2372
+ init_team_mapping();
1167
2373
  }
1168
2374
  });
1169
2375
 
1170
2376
  // src/sources/afl-tables.ts
1171
- import * as cheerio from "cheerio";
2377
+ import * as cheerio5 from "cheerio";
1172
2378
  function parseSeasonPage(html, year) {
1173
- const $ = cheerio.load(html);
2379
+ const $ = cheerio5.load(html);
1174
2380
  const results = [];
1175
2381
  let currentRound = 0;
1176
2382
  let currentRoundType = "HomeAndAway";
@@ -1178,17 +2384,18 @@ function parseSeasonPage(html, year) {
1178
2384
  $("table").each((_i, table) => {
1179
2385
  const $table = $(table);
1180
2386
  const text = $table.text().trim();
2387
+ const border = $table.attr("border");
1181
2388
  const roundMatch = /^Round\s+(\d+)/i.exec(text);
1182
- if (roundMatch?.[1] && !$table.attr("border")) {
2389
+ if (roundMatch?.[1] && border !== "1") {
1183
2390
  currentRound = Number.parseInt(roundMatch[1], 10);
1184
2391
  currentRoundType = inferRoundType(text);
1185
2392
  return;
1186
2393
  }
1187
- if (!$table.attr("border") && inferRoundType(text) === "Finals") {
2394
+ if (border !== "1" && inferRoundType(text) === "Finals") {
1188
2395
  currentRoundType = "Finals";
1189
2396
  return;
1190
2397
  }
1191
- if ($table.attr("border") !== "1") return;
2398
+ if (border !== "1") return;
1192
2399
  const rows = $table.find("tr");
1193
2400
  if (rows.length !== 2) return;
1194
2401
  const homeRow = $(rows[0]);
@@ -1265,7 +2472,7 @@ function parseDateFromInfo(text, year) {
1265
2472
  return parseAflTablesDate(text) ?? new Date(year, 0, 1);
1266
2473
  }
1267
2474
  function parseVenueFromInfo(html) {
1268
- const $ = cheerio.load(html);
2475
+ const $ = cheerio5.load(html);
1269
2476
  const venueLink = $("a[href*='venues']");
1270
2477
  if (venueLink.length > 0) {
1271
2478
  return venueLink.text().trim();
@@ -1278,7 +2485,114 @@ function parseAttendanceFromInfo(text) {
1278
2485
  if (!match?.[1]) return null;
1279
2486
  return Number.parseInt(match[1].replace(/,/g, ""), 10) || null;
1280
2487
  }
1281
- var AFL_TABLES_BASE, AflTablesClient;
2488
+ function parseAflTablesTeamStats(html, year) {
2489
+ const $ = cheerio5.load(html);
2490
+ const teamMap = /* @__PURE__ */ new Map();
2491
+ const tables = $("table");
2492
+ function parseTable(tableIdx, suffix) {
2493
+ if (tableIdx >= tables.length) return;
2494
+ const $table = $(tables[tableIdx]);
2495
+ const rows = $table.find("tr");
2496
+ if (rows.length < 2) return;
2497
+ const headers = [];
2498
+ $(rows[0]).find("td, th").each((_ci, cell) => {
2499
+ headers.push($(cell).text().trim());
2500
+ });
2501
+ const gpColIdx = headers.findIndex((h, i) => i > 0 && GP_HEADERS.has(h.toLowerCase()));
2502
+ for (let ri = 1; ri < rows.length; ri++) {
2503
+ const cells = $(rows[ri]).find("td");
2504
+ if (cells.length < 3) continue;
2505
+ const teamText = $(cells[0]).text().trim();
2506
+ if (teamText === "Totals" || !teamText) continue;
2507
+ const teamName = normaliseTeamName(teamText);
2508
+ if (!teamName) continue;
2509
+ if (!teamMap.has(teamName)) {
2510
+ teamMap.set(teamName, { gamesPlayed: 0, stats: {} });
2511
+ }
2512
+ const entry = teamMap.get(teamName);
2513
+ if (!entry) continue;
2514
+ if (gpColIdx >= 0 && suffix === "_for") {
2515
+ const gpVal = Number.parseFloat($(cells[gpColIdx]).text().trim().replace(/,/g, "")) || 0;
2516
+ entry.gamesPlayed = gpVal;
2517
+ }
2518
+ for (let ci = 1; ci < cells.length; ci++) {
2519
+ if (ci === gpColIdx) continue;
2520
+ const header = headers[ci];
2521
+ if (!header) continue;
2522
+ const value = Number.parseFloat($(cells[ci]).text().trim().replace(/,/g, "")) || 0;
2523
+ entry.stats[`${header}${suffix}`] = value;
2524
+ }
2525
+ }
2526
+ }
2527
+ parseTable(1, "_for");
2528
+ parseTable(2, "_against");
2529
+ const entries = [];
2530
+ for (const [team, data] of teamMap) {
2531
+ entries.push({
2532
+ season: year,
2533
+ team,
2534
+ gamesPlayed: data.gamesPlayed,
2535
+ stats: data.stats,
2536
+ source: "afl-tables"
2537
+ });
2538
+ }
2539
+ return entries;
2540
+ }
2541
+ function teamNameToAflTablesSlug(teamName) {
2542
+ return AFL_TABLES_SLUG_MAP.get(teamName);
2543
+ }
2544
+ function parseAflTablesPlayerList(html, teamName) {
2545
+ const $ = cheerio5.load(html);
2546
+ const players = [];
2547
+ const table = $("table.sortable").first();
2548
+ if (table.length === 0) return players;
2549
+ const rows = table.find("tbody tr");
2550
+ rows.each((_ri, row) => {
2551
+ const cells = $(row).find("td");
2552
+ if (cells.length < 8) return;
2553
+ const jumperText = $(cells[1]).text().trim();
2554
+ const playerText = $(cells[2]).text().trim();
2555
+ if (!playerText) return;
2556
+ const nameParts = playerText.split(",").map((s) => s.trim());
2557
+ const surname = nameParts[0] ?? "";
2558
+ const givenName = nameParts[1] ?? "";
2559
+ const dobText = $(cells[3]).text().trim();
2560
+ const htText = $(cells[4]).text().trim();
2561
+ const wtText = $(cells[5]).text().trim();
2562
+ const gamesRaw = $(cells[6]).text().trim();
2563
+ const gamesMatch = /^(\d+)/.exec(gamesRaw);
2564
+ const goalsText = $(cells[7]).text().trim();
2565
+ const debutText = cells.length > 9 ? $(cells[9]).text().trim() : "";
2566
+ const heightCm = htText ? Number.parseInt(htText, 10) || null : null;
2567
+ const weightKg = wtText ? Number.parseInt(wtText, 10) || null : null;
2568
+ const gamesPlayed = gamesMatch?.[1] ? Number.parseInt(gamesMatch[1], 10) || null : null;
2569
+ const goalsScored = goalsText ? Number.parseInt(goalsText, 10) || null : null;
2570
+ const jumperNumber = jumperText ? Number.parseInt(jumperText, 10) || null : null;
2571
+ const debutYearMatch = /(\d{4})/.exec(debutText);
2572
+ const debutYear = debutYearMatch?.[1] ? Number.parseInt(debutYearMatch[1], 10) || null : null;
2573
+ players.push({
2574
+ playerId: `AT_${teamName}_${surname}_${givenName}`.replace(/\s+/g, "_"),
2575
+ givenName,
2576
+ surname,
2577
+ displayName: givenName ? `${givenName} ${surname}` : surname,
2578
+ team: teamName,
2579
+ jumperNumber,
2580
+ position: null,
2581
+ dateOfBirth: dobText || null,
2582
+ heightCm,
2583
+ weightKg,
2584
+ gamesPlayed,
2585
+ goals: goalsScored,
2586
+ draftYear: null,
2587
+ draftPosition: null,
2588
+ draftType: null,
2589
+ debutYear,
2590
+ recruitedFrom: null
2591
+ });
2592
+ });
2593
+ return players;
2594
+ }
2595
+ var AFL_TABLES_BASE, AflTablesClient, GP_HEADERS, AFL_TABLES_SLUG_MAP;
1282
2596
  var init_afl_tables = __esm({
1283
2597
  "src/sources/afl-tables.ts"() {
1284
2598
  "use strict";
@@ -1286,6 +2600,7 @@ var init_afl_tables = __esm({
1286
2600
  init_errors();
1287
2601
  init_result();
1288
2602
  init_team_mapping();
2603
+ init_afl_tables_player_stats();
1289
2604
  init_match_results();
1290
2605
  AFL_TABLES_BASE = "https://afltables.com/afl/seas";
1291
2606
  AflTablesClient = class {
@@ -1322,157 +2637,420 @@ var init_afl_tables = __esm({
1322
2637
  );
1323
2638
  }
1324
2639
  }
1325
- };
1326
- }
1327
- });
1328
-
1329
- // src/sources/footywire.ts
1330
- import * as cheerio2 from "cheerio";
1331
- function parseMatchList(html, year) {
1332
- const $ = cheerio2.load(html);
1333
- const results = [];
1334
- let currentRound = 0;
1335
- let currentRoundType = "HomeAndAway";
1336
- $("tr").each((_i, row) => {
1337
- const roundHeader = $(row).find("td[colspan='7']");
1338
- if (roundHeader.length > 0) {
1339
- const text = roundHeader.text().trim();
1340
- currentRoundType = inferRoundType(text);
1341
- const roundMatch = /Round\s+(\d+)/i.exec(text);
1342
- if (roundMatch?.[1]) {
1343
- currentRound = Number.parseInt(roundMatch[1], 10);
1344
- }
1345
- return;
1346
- }
1347
- const cells = $(row).find("td.data");
1348
- if (cells.length < 5) return;
1349
- const dateText = $(cells[0]).text().trim();
1350
- const teamsCell = $(cells[1]);
1351
- const venue = $(cells[2]).text().trim();
1352
- const attendance = $(cells[3]).text().trim();
1353
- const scoreCell = $(cells[4]);
1354
- if (venue === "BYE") return;
1355
- const teamLinks = teamsCell.find("a");
1356
- if (teamLinks.length < 2) return;
1357
- const homeTeam = normaliseTeamName($(teamLinks[0]).text().trim());
1358
- const awayTeam = normaliseTeamName($(teamLinks[1]).text().trim());
1359
- const scoreText = scoreCell.text().trim();
1360
- const scoreMatch = /(\d+)-(\d+)/.exec(scoreText);
1361
- if (!scoreMatch) return;
1362
- const homePoints = Number.parseInt(scoreMatch[1] ?? "0", 10);
1363
- const awayPoints = Number.parseInt(scoreMatch[2] ?? "0", 10);
1364
- const scoreLink = scoreCell.find("a").attr("href") ?? "";
1365
- const midMatch = /mid=(\d+)/.exec(scoreLink);
1366
- const matchId = midMatch?.[1] ? `FW_${midMatch[1]}` : `FW_${year}_R${currentRound}_${homeTeam}`;
1367
- const date = parseFootyWireDate(dateText) ?? new Date(year, 0, 1);
1368
- const homeGoals = Math.floor(homePoints / 6);
1369
- const homeBehinds = homePoints - homeGoals * 6;
1370
- const awayGoals = Math.floor(awayPoints / 6);
1371
- const awayBehinds = awayPoints - awayGoals * 6;
1372
- results.push({
1373
- matchId,
1374
- season: year,
1375
- roundNumber: currentRound,
1376
- roundType: currentRoundType,
1377
- date,
1378
- venue,
1379
- homeTeam,
1380
- awayTeam,
1381
- homeGoals,
1382
- homeBehinds,
1383
- homePoints,
1384
- awayGoals,
1385
- awayBehinds,
1386
- awayPoints,
1387
- margin: homePoints - awayPoints,
1388
- q1Home: null,
1389
- q2Home: null,
1390
- q3Home: null,
1391
- q4Home: null,
1392
- q1Away: null,
1393
- q2Away: null,
1394
- q3Away: null,
1395
- q4Away: null,
1396
- status: "Complete",
1397
- attendance: attendance ? Number.parseInt(attendance, 10) || null : null,
1398
- venueState: null,
1399
- venueTimezone: null,
1400
- homeRushedBehinds: null,
1401
- awayRushedBehinds: null,
1402
- homeMinutesInFront: null,
1403
- awayMinutesInFront: null,
1404
- source: "footywire",
1405
- competition: "AFLM"
1406
- });
1407
- });
1408
- return results;
1409
- }
1410
- var FOOTYWIRE_BASE, FootyWireClient;
1411
- var init_footywire = __esm({
1412
- "src/sources/footywire.ts"() {
1413
- "use strict";
1414
- init_date_utils();
1415
- init_errors();
1416
- init_result();
1417
- init_team_mapping();
1418
- init_match_results();
1419
- FOOTYWIRE_BASE = "https://www.footywire.com/afl/footy";
1420
- FootyWireClient = class {
1421
- fetchFn;
1422
- constructor(options) {
1423
- this.fetchFn = options?.fetchFn ?? globalThis.fetch;
2640
+ /**
2641
+ * Fetch player statistics for an entire season from AFL Tables.
2642
+ *
2643
+ * Scrapes individual game pages linked from the season page.
2644
+ *
2645
+ * @param year - The season year.
2646
+ */
2647
+ async fetchSeasonPlayerStats(year) {
2648
+ const seasonUrl = `${AFL_TABLES_BASE}/${year}.html`;
2649
+ try {
2650
+ const seasonResponse = await this.fetchFn(seasonUrl, {
2651
+ headers: { "User-Agent": "Mozilla/5.0" }
2652
+ });
2653
+ if (!seasonResponse.ok) {
2654
+ return err(
2655
+ new ScrapeError(
2656
+ `AFL Tables request failed: ${seasonResponse.status} (${seasonUrl})`,
2657
+ "afl-tables"
2658
+ )
2659
+ );
2660
+ }
2661
+ const seasonHtml = await seasonResponse.text();
2662
+ const gameUrls = extractGameUrls(seasonHtml);
2663
+ if (gameUrls.length === 0) {
2664
+ return ok([]);
2665
+ }
2666
+ const results = parseSeasonPage(seasonHtml, year);
2667
+ const allStats = [];
2668
+ const batchSize = 5;
2669
+ for (let i = 0; i < gameUrls.length; i += batchSize) {
2670
+ const batch = gameUrls.slice(i, i + batchSize);
2671
+ const batchResults = await Promise.all(
2672
+ batch.map(async (gameUrl, batchIdx) => {
2673
+ try {
2674
+ const resp = await this.fetchFn(gameUrl, {
2675
+ headers: { "User-Agent": "Mozilla/5.0" }
2676
+ });
2677
+ if (!resp.ok) return [];
2678
+ const html = await resp.text();
2679
+ const urlMatch = /\/(\d+)\.html$/.exec(gameUrl);
2680
+ const matchId = urlMatch?.[1] ?? `${year}_${i + batchIdx}`;
2681
+ const globalIdx = i + batchIdx;
2682
+ const roundNumber = results[globalIdx]?.roundNumber ?? 0;
2683
+ return parseAflTablesGameStats(html, matchId, year, roundNumber);
2684
+ } catch {
2685
+ return [];
2686
+ }
2687
+ })
2688
+ );
2689
+ for (const stats of batchResults) {
2690
+ allStats.push(...stats);
2691
+ }
2692
+ if (i + batchSize < gameUrls.length) {
2693
+ await new Promise((resolve) => setTimeout(resolve, 300));
2694
+ }
2695
+ }
2696
+ return ok(allStats);
2697
+ } catch (cause) {
2698
+ return err(
2699
+ new ScrapeError(
2700
+ `AFL Tables player stats failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2701
+ "afl-tables"
2702
+ )
2703
+ );
2704
+ }
1424
2705
  }
1425
2706
  /**
1426
- * Fetch the HTML content of a FootyWire page.
2707
+ * Fetch team statistics from AFL Tables.
2708
+ *
2709
+ * Scrapes the season stats page which includes per-team aggregate stats.
2710
+ *
2711
+ * @param year - The season year.
2712
+ * @returns Array of team stats entries.
1427
2713
  */
1428
- async fetchHtml(url) {
2714
+ async fetchTeamStats(year) {
2715
+ const url = `https://afltables.com/afl/stats/${year}s.html`;
1429
2716
  try {
1430
2717
  const response = await this.fetchFn(url, {
1431
- headers: {
1432
- "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"
1433
- }
2718
+ headers: { "User-Agent": "Mozilla/5.0" }
1434
2719
  });
1435
2720
  if (!response.ok) {
1436
2721
  return err(
1437
- new ScrapeError(`FootyWire request failed: ${response.status} (${url})`, "footywire")
2722
+ new ScrapeError(
2723
+ `AFL Tables stats request failed: ${response.status} (${url})`,
2724
+ "afl-tables"
2725
+ )
1438
2726
  );
1439
2727
  }
1440
2728
  const html = await response.text();
1441
- return ok(html);
2729
+ const entries = parseAflTablesTeamStats(html, year);
2730
+ return ok(entries);
1442
2731
  } catch (cause) {
1443
2732
  return err(
1444
2733
  new ScrapeError(
1445
- `FootyWire request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1446
- "footywire"
2734
+ `AFL Tables team stats failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2735
+ "afl-tables"
1447
2736
  )
1448
2737
  );
1449
2738
  }
1450
2739
  }
1451
2740
  /**
1452
- * Fetch season match results from FootyWire.
2741
+ * Fetch player list from AFL Tables team page.
1453
2742
  *
1454
- * @param year - The season year.
1455
- * @returns Array of match results.
1456
- */
1457
- async fetchSeasonResults(year) {
1458
- const url = `${FOOTYWIRE_BASE}/ft_match_list?year=${year}`;
1459
- const htmlResult = await this.fetchHtml(url);
1460
- if (!htmlResult.success) {
1461
- return htmlResult;
2743
+ * Scrapes the team index page (e.g. `teams/swans_idx.html`) which lists
2744
+ * all players who have played for that team historically.
2745
+ *
2746
+ * @param teamName - Canonical team name (e.g. "Sydney Swans").
2747
+ * @returns Array of player details (without source/competition fields).
2748
+ */
2749
+ async fetchPlayerList(teamName) {
2750
+ const slug = teamNameToAflTablesSlug(teamName);
2751
+ if (!slug) {
2752
+ return err(new ScrapeError(`No AFL Tables slug mapping for team: ${teamName}`, "afl-tables"));
1462
2753
  }
2754
+ const url = `https://afltables.com/afl/stats/alltime/${slug}.html`;
1463
2755
  try {
1464
- const results = parseMatchList(htmlResult.data, year);
1465
- return ok(results);
2756
+ const response = await this.fetchFn(url, {
2757
+ headers: { "User-Agent": "Mozilla/5.0" }
2758
+ });
2759
+ if (!response.ok) {
2760
+ return err(
2761
+ new ScrapeError(`AFL Tables request failed: ${response.status} (${url})`, "afl-tables")
2762
+ );
2763
+ }
2764
+ const html = await response.text();
2765
+ const players = parseAflTablesPlayerList(html, teamName);
2766
+ return ok(players);
1466
2767
  } catch (cause) {
1467
2768
  return err(
1468
2769
  new ScrapeError(
1469
- `Failed to parse match list: ${cause instanceof Error ? cause.message : String(cause)}`,
1470
- "footywire"
2770
+ `AFL Tables player list failed: ${cause instanceof Error ? cause.message : String(cause)}`,
2771
+ "afl-tables"
1471
2772
  )
1472
2773
  );
1473
2774
  }
1474
2775
  }
1475
2776
  };
2777
+ GP_HEADERS = /* @__PURE__ */ new Set(["gm", "gp", "p", "mp", "games"]);
2778
+ AFL_TABLES_SLUG_MAP = /* @__PURE__ */ new Map([
2779
+ ["Adelaide Crows", "adelaide"],
2780
+ ["Brisbane Lions", "brisbane"],
2781
+ ["Carlton", "carlton"],
2782
+ ["Collingwood", "collingwood"],
2783
+ ["Essendon", "essendon"],
2784
+ ["Fremantle", "fremantle"],
2785
+ ["Geelong Cats", "geelong"],
2786
+ ["Gold Coast Suns", "goldcoast"],
2787
+ ["GWS Giants", "gws"],
2788
+ ["Hawthorn", "hawthorn"],
2789
+ ["Melbourne", "melbourne"],
2790
+ ["North Melbourne", "kangaroos"],
2791
+ ["Port Adelaide", "padelaide"],
2792
+ ["Richmond", "richmond"],
2793
+ ["St Kilda", "stkilda"],
2794
+ ["Sydney Swans", "swans"],
2795
+ ["West Coast Eagles", "westcoast"],
2796
+ ["Western Bulldogs", "bullldogs"],
2797
+ ["Fitzroy", "fitzroy"],
2798
+ ["University", "university"]
2799
+ ]);
2800
+ }
2801
+ });
2802
+
2803
+ // src/transforms/computed-ladder.ts
2804
+ function computeLadder(results, upToRound) {
2805
+ const teams = /* @__PURE__ */ new Map();
2806
+ const filtered = upToRound != null ? results.filter((r) => r.roundType === "HomeAndAway" && r.roundNumber <= upToRound) : results.filter((r) => r.roundType === "HomeAndAway");
2807
+ for (const match of filtered) {
2808
+ if (match.status !== "Complete") continue;
2809
+ const home = getOrCreate(teams, match.homeTeam);
2810
+ const away = getOrCreate(teams, match.awayTeam);
2811
+ home.played++;
2812
+ away.played++;
2813
+ home.pointsFor += match.homePoints;
2814
+ home.pointsAgainst += match.awayPoints;
2815
+ away.pointsFor += match.awayPoints;
2816
+ away.pointsAgainst += match.homePoints;
2817
+ if (match.homePoints > match.awayPoints) {
2818
+ home.wins++;
2819
+ away.losses++;
2820
+ } else if (match.awayPoints > match.homePoints) {
2821
+ away.wins++;
2822
+ home.losses++;
2823
+ } else {
2824
+ home.draws++;
2825
+ away.draws++;
2826
+ }
2827
+ }
2828
+ const entries = [...teams.entries()].map(([teamName, acc]) => {
2829
+ const percentage = acc.pointsAgainst === 0 ? 0 : acc.pointsFor / acc.pointsAgainst * 100;
2830
+ const premiershipsPoints = acc.wins * 4 + acc.draws * 2;
2831
+ return {
2832
+ position: 0,
2833
+ // filled below after sorting
2834
+ team: teamName,
2835
+ played: acc.played,
2836
+ wins: acc.wins,
2837
+ losses: acc.losses,
2838
+ draws: acc.draws,
2839
+ pointsFor: acc.pointsFor,
2840
+ pointsAgainst: acc.pointsAgainst,
2841
+ percentage,
2842
+ premiershipsPoints,
2843
+ form: null
2844
+ };
2845
+ });
2846
+ entries.sort((a, b) => {
2847
+ if (b.premiershipsPoints !== a.premiershipsPoints) {
2848
+ return b.premiershipsPoints - a.premiershipsPoints;
2849
+ }
2850
+ return b.percentage - a.percentage;
2851
+ });
2852
+ for (let i = 0; i < entries.length; i++) {
2853
+ const entry = entries[i];
2854
+ if (entry) {
2855
+ entries[i] = { ...entry, position: i + 1 };
2856
+ }
2857
+ }
2858
+ return entries;
2859
+ }
2860
+ function getOrCreate(map, team) {
2861
+ let acc = map.get(team);
2862
+ if (!acc) {
2863
+ acc = { played: 0, wins: 0, losses: 0, draws: 0, pointsFor: 0, pointsAgainst: 0 };
2864
+ map.set(team, acc);
2865
+ }
2866
+ return acc;
2867
+ }
2868
+ var init_computed_ladder = __esm({
2869
+ "src/transforms/computed-ladder.ts"() {
2870
+ "use strict";
2871
+ }
2872
+ });
2873
+
2874
+ // src/transforms/ladder.ts
2875
+ function transformLadderEntries(entries) {
2876
+ return entries.map((entry) => {
2877
+ const record = entry.thisSeasonRecord;
2878
+ const wl = record?.winLossRecord;
2879
+ return {
2880
+ position: entry.position,
2881
+ team: normaliseTeamName(entry.team.name),
2882
+ played: entry.played ?? wl?.played ?? 0,
2883
+ wins: wl?.wins ?? 0,
2884
+ losses: wl?.losses ?? 0,
2885
+ draws: wl?.draws ?? 0,
2886
+ pointsFor: entry.pointsFor ?? 0,
2887
+ pointsAgainst: entry.pointsAgainst ?? 0,
2888
+ percentage: record?.percentage ?? 0,
2889
+ premiershipsPoints: record?.aggregatePoints ?? 0,
2890
+ form: entry.form ?? null
2891
+ };
2892
+ });
2893
+ }
2894
+ var init_ladder = __esm({
2895
+ "src/transforms/ladder.ts"() {
2896
+ "use strict";
2897
+ init_team_mapping();
2898
+ }
2899
+ });
2900
+
2901
+ // src/api/ladder.ts
2902
+ async function fetchLadder(query) {
2903
+ const competition = query.competition ?? "AFLM";
2904
+ if (query.source === "squiggle") {
2905
+ const client2 = new SquiggleClient();
2906
+ const result = await client2.fetchStandings(query.season, query.round ?? void 0);
2907
+ if (!result.success) return result;
2908
+ return ok({
2909
+ season: query.season,
2910
+ roundNumber: query.round ?? null,
2911
+ entries: transformSquiggleStandings(result.data.standings),
2912
+ competition
2913
+ });
2914
+ }
2915
+ if (query.source === "afl-tables") {
2916
+ const atClient = new AflTablesClient();
2917
+ const resultsResult = await atClient.fetchSeasonResults(query.season);
2918
+ if (!resultsResult.success) return resultsResult;
2919
+ const entries2 = computeLadder(resultsResult.data, query.round ?? void 0);
2920
+ return ok({
2921
+ season: query.season,
2922
+ roundNumber: query.round ?? null,
2923
+ entries: entries2,
2924
+ competition
2925
+ });
2926
+ }
2927
+ if (query.source !== "afl-api") {
2928
+ return err(
2929
+ new UnsupportedSourceError(
2930
+ "Ladder data is only available from the AFL API, AFL Tables, or Squiggle sources.",
2931
+ query.source
2932
+ )
2933
+ );
2934
+ }
2935
+ const client = new AflApiClient();
2936
+ const seasonResult = await client.resolveCompSeason(competition, query.season);
2937
+ if (!seasonResult.success) return seasonResult;
2938
+ let roundId;
2939
+ if (query.round != null) {
2940
+ const roundsResult = await client.resolveRounds(seasonResult.data);
2941
+ if (!roundsResult.success) return roundsResult;
2942
+ const round = roundsResult.data.find((r) => r.roundNumber === query.round);
2943
+ if (round) {
2944
+ roundId = round.id;
2945
+ }
2946
+ }
2947
+ const ladderResult = await client.fetchLadder(seasonResult.data, roundId);
2948
+ if (!ladderResult.success) return ladderResult;
2949
+ const firstLadder = ladderResult.data.ladders[0];
2950
+ const entries = firstLadder ? transformLadderEntries(firstLadder.entries) : [];
2951
+ return ok({
2952
+ season: query.season,
2953
+ roundNumber: ladderResult.data.round?.roundNumber ?? null,
2954
+ entries,
2955
+ competition
2956
+ });
2957
+ }
2958
+ var init_ladder2 = __esm({
2959
+ "src/api/ladder.ts"() {
2960
+ "use strict";
2961
+ init_errors();
2962
+ init_result();
2963
+ init_afl_api();
2964
+ init_afl_tables();
2965
+ init_squiggle();
2966
+ init_computed_ladder();
2967
+ init_ladder();
2968
+ init_squiggle2();
2969
+ }
2970
+ });
2971
+
2972
+ // src/transforms/lineup.ts
2973
+ function transformMatchRoster(roster, season, roundNumber, competition) {
2974
+ const homeTeamId = roster.match.homeTeamId;
2975
+ const awayTeamId = roster.match.awayTeamId;
2976
+ const homeTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === homeTeamId);
2977
+ const awayTeamPlayers = roster.teamPlayers.find((tp) => tp.teamId === awayTeamId);
2978
+ const mapPlayers = (players) => players.map((p) => {
2979
+ const inner = p.player.player;
2980
+ const position = p.player.position ?? null;
2981
+ return {
2982
+ playerId: inner.playerId,
2983
+ givenName: inner.playerName.givenName,
2984
+ surname: inner.playerName.surname,
2985
+ displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
2986
+ jumperNumber: p.jumperNumber ?? null,
2987
+ position,
2988
+ isEmergency: position !== null && EMERGENCY_POSITIONS.has(position),
2989
+ isSubstitute: position !== null && SUBSTITUTE_POSITIONS.has(position)
2990
+ };
2991
+ });
2992
+ return {
2993
+ matchId: roster.match.matchId,
2994
+ season,
2995
+ roundNumber,
2996
+ homeTeam: normaliseTeamName(roster.match.homeTeam.name),
2997
+ awayTeam: normaliseTeamName(roster.match.awayTeam.name),
2998
+ homePlayers: homeTeamPlayers ? mapPlayers(homeTeamPlayers.players) : [],
2999
+ awayPlayers: awayTeamPlayers ? mapPlayers(awayTeamPlayers.players) : [],
3000
+ competition
3001
+ };
3002
+ }
3003
+ var EMERGENCY_POSITIONS, SUBSTITUTE_POSITIONS;
3004
+ var init_lineup = __esm({
3005
+ "src/transforms/lineup.ts"() {
3006
+ "use strict";
3007
+ init_team_mapping();
3008
+ EMERGENCY_POSITIONS = /* @__PURE__ */ new Set(["EMG", "EMERG"]);
3009
+ SUBSTITUTE_POSITIONS = /* @__PURE__ */ new Set(["SUB", "INT"]);
3010
+ }
3011
+ });
3012
+
3013
+ // src/api/lineup.ts
3014
+ async function fetchLineup(query) {
3015
+ const competition = query.competition ?? "AFLM";
3016
+ if (query.source !== "afl-api") {
3017
+ return err(
3018
+ new UnsupportedSourceError(
3019
+ "Lineup data is only available from the AFL API source.",
3020
+ query.source
3021
+ )
3022
+ );
3023
+ }
3024
+ const client = new AflApiClient();
3025
+ if (query.matchId) {
3026
+ const rosterResult = await client.fetchMatchRoster(query.matchId);
3027
+ if (!rosterResult.success) return rosterResult;
3028
+ return ok([transformMatchRoster(rosterResult.data, query.season, query.round, competition)]);
3029
+ }
3030
+ const seasonResult = await client.resolveCompSeason(competition, query.season);
3031
+ if (!seasonResult.success) return seasonResult;
3032
+ const matchItems = await client.fetchRoundMatchItemsByNumber(seasonResult.data, query.round);
3033
+ if (!matchItems.success) return matchItems;
3034
+ if (matchItems.data.length === 0) {
3035
+ return err(new AflApiError(`No matches found for round ${query.round}`));
3036
+ }
3037
+ const rosterResults = await Promise.all(
3038
+ matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
3039
+ );
3040
+ const lineups = [];
3041
+ for (const rosterResult of rosterResults) {
3042
+ if (!rosterResult.success) return rosterResult;
3043
+ lineups.push(transformMatchRoster(rosterResult.data, query.season, query.round, competition));
3044
+ }
3045
+ return ok(lineups);
3046
+ }
3047
+ var init_lineup2 = __esm({
3048
+ "src/api/lineup.ts"() {
3049
+ "use strict";
3050
+ init_errors();
3051
+ init_result();
3052
+ init_afl_api();
3053
+ init_lineup();
1476
3054
  }
1477
3055
  });
1478
3056
 
@@ -1514,19 +3092,134 @@ async function fetchMatchResults(query) {
1514
3092
  }
1515
3093
  return result;
1516
3094
  }
3095
+ case "squiggle": {
3096
+ const client = new SquiggleClient();
3097
+ const result = await client.fetchGames(query.season, query.round ?? void 0, 100);
3098
+ if (!result.success) return result;
3099
+ return ok(transformSquiggleGamesToResults(result.data.games, query.season));
3100
+ }
1517
3101
  default:
1518
3102
  return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
1519
3103
  }
1520
3104
  }
1521
- var init_match_results2 = __esm({
1522
- "src/api/match-results.ts"() {
3105
+ var init_match_results2 = __esm({
3106
+ "src/api/match-results.ts"() {
3107
+ "use strict";
3108
+ init_errors();
3109
+ init_result();
3110
+ init_afl_api();
3111
+ init_afl_tables();
3112
+ init_footywire();
3113
+ init_squiggle();
3114
+ init_match_results();
3115
+ init_squiggle2();
3116
+ }
3117
+ });
3118
+
3119
+ // src/api/player-details.ts
3120
+ async function resolveTeamId(client, teamName, competition) {
3121
+ const teamType = competition === "AFLW" ? "WOMEN" : "MEN";
3122
+ const result = await client.fetchTeams(teamType);
3123
+ if (!result.success) return result;
3124
+ const normalised = normaliseTeamName(teamName);
3125
+ const match = result.data.find((t) => normaliseTeamName(t.name) === normalised);
3126
+ if (!match) {
3127
+ return err(new ValidationError(`Team not found: ${teamName}`));
3128
+ }
3129
+ return ok(String(match.id));
3130
+ }
3131
+ async function fetchFromAflApi(query) {
3132
+ const client = new AflApiClient();
3133
+ const competition = query.competition ?? "AFLM";
3134
+ const season = query.season ?? (/* @__PURE__ */ new Date()).getFullYear();
3135
+ const [teamIdResult, seasonResult] = await Promise.all([
3136
+ resolveTeamId(client, query.team, competition),
3137
+ client.resolveCompSeason(competition, season)
3138
+ ]);
3139
+ if (!teamIdResult.success) return teamIdResult;
3140
+ if (!seasonResult.success) return seasonResult;
3141
+ const teamId = Number.parseInt(teamIdResult.data, 10);
3142
+ if (Number.isNaN(teamId)) {
3143
+ return err(new ValidationError(`Invalid team ID: ${teamIdResult.data}`));
3144
+ }
3145
+ const squadResult = await client.fetchSquad(teamId, seasonResult.data);
3146
+ if (!squadResult.success) return squadResult;
3147
+ const teamName = normaliseTeamName(squadResult.data.squad.team?.name ?? query.team);
3148
+ const players = squadResult.data.squad.players.map((p) => ({
3149
+ playerId: p.player.providerId ?? String(p.player.id),
3150
+ givenName: p.player.firstName,
3151
+ surname: p.player.surname,
3152
+ displayName: `${p.player.firstName} ${p.player.surname}`,
3153
+ team: teamName,
3154
+ jumperNumber: p.jumperNumber ?? null,
3155
+ position: p.position ?? null,
3156
+ dateOfBirth: p.player.dateOfBirth ?? null,
3157
+ heightCm: p.player.heightInCm ?? null,
3158
+ weightKg: p.player.weightInKg ?? null,
3159
+ gamesPlayed: null,
3160
+ goals: null,
3161
+ draftYear: p.player.draftYear ? Number.parseInt(p.player.draftYear, 10) || null : null,
3162
+ draftPosition: p.player.draftPosition ? Number.parseInt(p.player.draftPosition, 10) || null : null,
3163
+ draftType: p.player.draftType ?? null,
3164
+ debutYear: p.player.debutYear ? Number.parseInt(p.player.debutYear, 10) || null : null,
3165
+ recruitedFrom: p.player.recruitedFrom ?? null,
3166
+ source: "afl-api",
3167
+ competition
3168
+ }));
3169
+ return ok(players);
3170
+ }
3171
+ async function fetchFromFootyWire(query) {
3172
+ const client = new FootyWireClient();
3173
+ const competition = query.competition ?? "AFLM";
3174
+ const teamName = normaliseTeamName(query.team);
3175
+ const result = await client.fetchPlayerList(teamName);
3176
+ if (!result.success) return result;
3177
+ const players = result.data.map((p) => ({
3178
+ ...p,
3179
+ source: "footywire",
3180
+ competition
3181
+ }));
3182
+ return ok(players);
3183
+ }
3184
+ async function fetchFromAflTables(query) {
3185
+ const client = new AflTablesClient();
3186
+ const competition = query.competition ?? "AFLM";
3187
+ const teamName = normaliseTeamName(query.team);
3188
+ const result = await client.fetchPlayerList(teamName);
3189
+ if (!result.success) return result;
3190
+ const players = result.data.map((p) => ({
3191
+ ...p,
3192
+ source: "afl-tables",
3193
+ competition
3194
+ }));
3195
+ return ok(players);
3196
+ }
3197
+ async function fetchPlayerDetails(query) {
3198
+ switch (query.source) {
3199
+ case "afl-api":
3200
+ return fetchFromAflApi(query);
3201
+ case "footywire":
3202
+ return fetchFromFootyWire(query);
3203
+ case "afl-tables":
3204
+ return fetchFromAflTables(query);
3205
+ default:
3206
+ return err(
3207
+ new UnsupportedSourceError(
3208
+ `Source "${query.source}" is not supported for player details. Use "afl-api", "footywire", or "afl-tables".`,
3209
+ query.source
3210
+ )
3211
+ );
3212
+ }
3213
+ }
3214
+ var init_player_details = __esm({
3215
+ "src/api/player-details.ts"() {
1523
3216
  "use strict";
1524
3217
  init_errors();
1525
3218
  init_result();
3219
+ init_team_mapping();
1526
3220
  init_afl_api();
1527
3221
  init_afl_tables();
1528
3222
  init_footywire();
1529
- init_match_results();
1530
3223
  }
1531
3224
  });
1532
3225
 
@@ -1636,15 +3329,26 @@ async function fetchPlayerStats(query) {
1636
3329
  case "afl-api": {
1637
3330
  const client = new AflApiClient();
1638
3331
  if (query.matchId) {
1639
- const result = await client.fetchPlayerStats(query.matchId);
1640
- if (!result.success) return result;
3332
+ const [rosterResult, statsResult] = await Promise.all([
3333
+ client.fetchMatchRoster(query.matchId),
3334
+ client.fetchPlayerStats(query.matchId)
3335
+ ]);
3336
+ if (!statsResult.success) return statsResult;
3337
+ const teamIdMap2 = /* @__PURE__ */ new Map();
3338
+ if (rosterResult.success) {
3339
+ const match = rosterResult.data.match;
3340
+ teamIdMap2.set(match.homeTeamId, match.homeTeam.name);
3341
+ teamIdMap2.set(match.awayTeamId, match.awayTeam.name);
3342
+ }
1641
3343
  return ok(
1642
3344
  transformPlayerStats(
1643
- result.data,
3345
+ statsResult.data,
1644
3346
  query.matchId,
1645
3347
  query.season,
1646
3348
  query.round ?? 0,
1647
- competition
3349
+ competition,
3350
+ "afl-api",
3351
+ teamIdMap2.size > 0 ? teamIdMap2 : void 0
1648
3352
  )
1649
3353
  );
1650
3354
  }
@@ -1685,20 +3389,44 @@ async function fetchPlayerStats(query) {
1685
3389
  }
1686
3390
  return ok(allStats);
1687
3391
  }
1688
- case "footywire":
1689
- return err(
1690
- new UnsupportedSourceError(
1691
- "Player stats from FootyWire are not yet supported. Use source: 'afl-api'.",
1692
- "footywire"
1693
- )
1694
- );
1695
- case "afl-tables":
1696
- return err(
1697
- new UnsupportedSourceError(
1698
- "Player stats from AFL Tables are not yet supported. Use source: 'afl-api'.",
1699
- "afl-tables"
1700
- )
1701
- );
3392
+ case "footywire": {
3393
+ const fwClient = new FootyWireClient();
3394
+ const idsResult = await fwClient.fetchSeasonMatchIds(query.season);
3395
+ if (!idsResult.success) return idsResult;
3396
+ const matchIds = idsResult.data;
3397
+ if (matchIds.length === 0) {
3398
+ return ok([]);
3399
+ }
3400
+ const allStats = [];
3401
+ const batchSize = 5;
3402
+ for (let i = 0; i < matchIds.length; i += batchSize) {
3403
+ const batch = matchIds.slice(i, i + batchSize);
3404
+ const results = await Promise.all(
3405
+ batch.map((mid) => fwClient.fetchMatchPlayerStats(mid, query.season, query.round ?? 0))
3406
+ );
3407
+ for (const result of results) {
3408
+ if (result.success) {
3409
+ allStats.push(...result.data);
3410
+ }
3411
+ }
3412
+ if (i + batchSize < matchIds.length) {
3413
+ await new Promise((resolve) => setTimeout(resolve, 500));
3414
+ }
3415
+ }
3416
+ if (query.round != null) {
3417
+ return ok(allStats.filter((s) => s.roundNumber === query.round));
3418
+ }
3419
+ return ok(allStats);
3420
+ }
3421
+ case "afl-tables": {
3422
+ const atClient = new AflTablesClient();
3423
+ const atResult = await atClient.fetchSeasonPlayerStats(query.season);
3424
+ if (!atResult.success) return atResult;
3425
+ if (query.round != null) {
3426
+ return ok(atResult.data.filter((s) => s.roundNumber === query.round));
3427
+ }
3428
+ return atResult;
3429
+ }
1702
3430
  default:
1703
3431
  return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
1704
3432
  }
@@ -1709,28 +3437,63 @@ var init_player_stats2 = __esm({
1709
3437
  init_errors();
1710
3438
  init_result();
1711
3439
  init_afl_api();
3440
+ init_afl_tables();
3441
+ init_footywire();
1712
3442
  init_player_stats();
1713
3443
  }
1714
3444
  });
1715
3445
 
3446
+ // src/api/team-stats.ts
3447
+ async function fetchTeamStats(query) {
3448
+ const summaryType = query.summaryType ?? "totals";
3449
+ switch (query.source) {
3450
+ case "footywire": {
3451
+ const client = new FootyWireClient();
3452
+ return client.fetchTeamStats(query.season, summaryType);
3453
+ }
3454
+ case "afl-tables": {
3455
+ const client = new AflTablesClient();
3456
+ return client.fetchTeamStats(query.season);
3457
+ }
3458
+ case "afl-api":
3459
+ case "squiggle":
3460
+ return err(
3461
+ new UnsupportedSourceError(
3462
+ `Team stats are not available from ${query.source}. Use "footywire" or "afl-tables".`,
3463
+ query.source
3464
+ )
3465
+ );
3466
+ default:
3467
+ return err(new UnsupportedSourceError(`Unsupported source: ${query.source}`, query.source));
3468
+ }
3469
+ }
3470
+ var init_team_stats = __esm({
3471
+ "src/api/team-stats.ts"() {
3472
+ "use strict";
3473
+ init_errors();
3474
+ init_result();
3475
+ init_afl_tables();
3476
+ init_footywire();
3477
+ }
3478
+ });
3479
+
1716
3480
  // src/api/teams.ts
1717
3481
  function teamTypeForComp(comp) {
1718
3482
  return comp === "AFLW" ? "WOMEN" : "MEN";
1719
3483
  }
1720
3484
  async function fetchTeams(query) {
1721
3485
  const client = new AflApiClient();
1722
- const teamType = query?.teamType ?? (query?.competition ? teamTypeForComp(query.competition) : void 0);
3486
+ const teamType = query?.teamType ?? teamTypeForComp(query?.competition ?? "AFLM");
1723
3487
  const result = await client.fetchTeams(teamType);
1724
3488
  if (!result.success) return result;
1725
3489
  const competition = query?.competition ?? "AFLM";
1726
- return ok(
1727
- result.data.map((t) => ({
1728
- teamId: String(t.id),
1729
- name: normaliseTeamName(t.name),
1730
- abbreviation: t.abbreviation ?? "",
1731
- competition
1732
- }))
1733
- );
3490
+ const teams = result.data.map((t) => ({
3491
+ teamId: String(t.id),
3492
+ name: normaliseTeamName(t.name),
3493
+ abbreviation: t.abbreviation ?? "",
3494
+ competition
3495
+ })).filter((t) => AFL_SENIOR_TEAMS.has(t.name));
3496
+ return ok(teams);
1734
3497
  }
1735
3498
  async function fetchSquad(query) {
1736
3499
  const client = new AflApiClient();
@@ -1781,11 +3544,14 @@ var init_teams = __esm({
1781
3544
  var init_index = __esm({
1782
3545
  "src/index.ts"() {
1783
3546
  "use strict";
3547
+ init_coaches_votes();
1784
3548
  init_fixture();
1785
3549
  init_ladder2();
1786
3550
  init_lineup2();
1787
3551
  init_match_results2();
3552
+ init_player_details();
1788
3553
  init_player_stats2();
3554
+ init_team_stats();
1789
3555
  init_teams();
1790
3556
  }
1791
3557
  });
@@ -1834,7 +3600,7 @@ var init_json = __esm({
1834
3600
  // src/cli/formatters/table.ts
1835
3601
  function toDisplayValue(value) {
1836
3602
  if (value === null || value === void 0) return "-";
1837
- if (value instanceof Date) return value.toISOString().slice(0, 16).replace("T", " ");
3603
+ if (value instanceof Date) return AEST_COMPACT_FORMATTER.format(value);
1838
3604
  if (typeof value === "object") return JSON.stringify(value);
1839
3605
  return String(value);
1840
3606
  }
@@ -1908,9 +3674,18 @@ function formatTable(data, options = {}) {
1908
3674
  });
1909
3675
  return [header, separator, ...rows].join("\n");
1910
3676
  }
3677
+ var AEST_COMPACT_FORMATTER;
1911
3678
  var init_table = __esm({
1912
3679
  "src/cli/formatters/table.ts"() {
1913
3680
  "use strict";
3681
+ AEST_COMPACT_FORMATTER = new Intl.DateTimeFormat("en-AU", {
3682
+ timeZone: "Australia/Melbourne",
3683
+ day: "numeric",
3684
+ month: "short",
3685
+ hour: "numeric",
3686
+ minute: "2-digit",
3687
+ hour12: true
3688
+ });
1914
3689
  }
1915
3690
  });
1916
3691
 
@@ -1950,7 +3725,7 @@ var init_formatters = __esm({
1950
3725
 
1951
3726
  // src/cli/ui.ts
1952
3727
  import { spinner } from "@clack/prompts";
1953
- import pc from "picocolors";
3728
+ import pc2 from "picocolors";
1954
3729
  async function withSpinner(message, fn) {
1955
3730
  if (!isTTY) {
1956
3731
  return fn();
@@ -1968,7 +3743,7 @@ async function withSpinner(message, fn) {
1968
3743
  }
1969
3744
  function showSummary(message) {
1970
3745
  if (!isTTY) return;
1971
- console.error(pc.dim(message));
3746
+ console.error(pc2.dim(message));
1972
3747
  }
1973
3748
  var isTTY;
1974
3749
  var init_ui = __esm({
@@ -1978,6 +3753,86 @@ var init_ui = __esm({
1978
3753
  }
1979
3754
  });
1980
3755
 
3756
+ // src/cli/validation.ts
3757
+ function validateSeason(raw) {
3758
+ const season = Number(raw);
3759
+ if (Number.isNaN(season) || !Number.isInteger(season)) {
3760
+ throw new Error(`Invalid season: "${raw}" \u2014 season must be a number (e.g. 2025)`);
3761
+ }
3762
+ if (season < 1897 || season > 2100) {
3763
+ throw new Error(`Invalid season: ${season} \u2014 must be between 1897 and 2100`);
3764
+ }
3765
+ return season;
3766
+ }
3767
+ function validateOptionalSeason(raw) {
3768
+ if (raw != null) return validateSeason(raw);
3769
+ return void 0;
3770
+ }
3771
+ function resolveDefaultSeason(competition = "AFLM") {
3772
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
3773
+ return competition === "AFLW" ? year - 1 : year;
3774
+ }
3775
+ function validateRound(raw) {
3776
+ const round = Number(raw);
3777
+ if (Number.isNaN(round) || !Number.isInteger(round) || round < 0) {
3778
+ throw new Error(`Invalid round: "${raw}" \u2014 round must be a non-negative integer`);
3779
+ }
3780
+ return round;
3781
+ }
3782
+ function validateFormat(raw) {
3783
+ if (raw == null) return void 0;
3784
+ const lower = raw.toLowerCase();
3785
+ if (VALID_FORMATS.includes(lower)) {
3786
+ return lower;
3787
+ }
3788
+ throw new Error(`Invalid format: "${raw}" \u2014 valid formats are: ${VALID_FORMATS.join(", ")}`);
3789
+ }
3790
+ function validateCompetition(raw) {
3791
+ const upper = raw.toUpperCase();
3792
+ if (upper === "AFLM" || upper === "AFLW") {
3793
+ return upper;
3794
+ }
3795
+ throw new Error(
3796
+ `Invalid competition: "${raw}" \u2014 valid values are: ${VALID_COMPETITIONS.join(", ")}`
3797
+ );
3798
+ }
3799
+ function validateOptionalCompetition(raw) {
3800
+ if (raw == null) return void 0;
3801
+ return validateCompetition(raw);
3802
+ }
3803
+ function validateSource(raw) {
3804
+ if (VALID_SOURCES.includes(raw)) {
3805
+ return raw;
3806
+ }
3807
+ throw new Error(`Invalid source: "${raw}" \u2014 valid sources are: ${VALID_SOURCES.join(", ")}`);
3808
+ }
3809
+ function resolveTeamIdentifier(raw, teams) {
3810
+ const trimmed = raw.trim();
3811
+ if (/^\d+$/.test(trimmed)) {
3812
+ return trimmed;
3813
+ }
3814
+ const canonical = normaliseTeamName(trimmed);
3815
+ const byCanonical = teams.find((t) => t.name === canonical);
3816
+ if (byCanonical) return byCanonical.teamId;
3817
+ const lower = trimmed.toLowerCase();
3818
+ const byName = teams.find((t) => t.name.toLowerCase() === lower);
3819
+ if (byName) return byName.teamId;
3820
+ const byAbbrev = teams.find((t) => t.abbreviation.toLowerCase() === lower);
3821
+ if (byAbbrev) return byAbbrev.teamId;
3822
+ const validNames = teams.map((t) => `${t.name} (${t.abbreviation})`).join(", ");
3823
+ throw new Error(`Unknown team: "${raw}" \u2014 valid teams are: ${validNames}`);
3824
+ }
3825
+ var VALID_SOURCES, VALID_COMPETITIONS, VALID_FORMATS;
3826
+ var init_validation2 = __esm({
3827
+ "src/cli/validation.ts"() {
3828
+ "use strict";
3829
+ init_team_mapping();
3830
+ VALID_SOURCES = ["afl-api", "footywire", "afl-tables", "squiggle"];
3831
+ VALID_COMPETITIONS = ["AFLM", "AFLW"];
3832
+ VALID_FORMATS = ["table", "json", "csv"];
3833
+ }
3834
+ });
3835
+
1981
3836
  // src/cli/commands/matches.ts
1982
3837
  var matches_exports = {};
1983
3838
  __export(matches_exports, {
@@ -1991,6 +3846,7 @@ var init_matches = __esm({
1991
3846
  init_index();
1992
3847
  init_formatters();
1993
3848
  init_ui();
3849
+ init_validation2();
1994
3850
  DEFAULT_COLUMNS = [
1995
3851
  { key: "date", label: "Date", maxWidth: 16 },
1996
3852
  { key: "roundNumber", label: "Round", maxWidth: 6 },
@@ -2020,16 +3876,14 @@ var init_matches = __esm({
2020
3876
  full: { type: "boolean", description: "Show all columns in table output" }
2021
3877
  },
2022
3878
  async run({ args }) {
2023
- const season = Number(args.season);
2024
- const round = args.round ? Number(args.round) : void 0;
3879
+ const season = validateSeason(args.season);
3880
+ const round = args.round ? validateRound(args.round) : void 0;
3881
+ const source = validateSource(args.source);
3882
+ const competition = validateCompetition(args.competition);
3883
+ const format = validateFormat(args.format);
2025
3884
  const result = await withSpinner(
2026
3885
  "Fetching match results\u2026",
2027
- () => fetchMatchResults({
2028
- source: args.source,
2029
- season,
2030
- round,
2031
- competition: args.competition
2032
- })
3886
+ () => fetchMatchResults({ source, season, round, competition })
2033
3887
  );
2034
3888
  if (!result.success) {
2035
3889
  throw result.error;
@@ -2039,7 +3893,7 @@ var init_matches = __esm({
2039
3893
  const formatOptions = {
2040
3894
  json: args.json,
2041
3895
  csv: args.csv,
2042
- format: args.format,
3896
+ format,
2043
3897
  full: args.full,
2044
3898
  columns: DEFAULT_COLUMNS
2045
3899
  };
@@ -2062,6 +3916,7 @@ var init_stats = __esm({
2062
3916
  init_index();
2063
3917
  init_formatters();
2064
3918
  init_ui();
3919
+ init_validation2();
2065
3920
  DEFAULT_COLUMNS2 = [
2066
3921
  { key: "displayName", label: "Player", maxWidth: 22 },
2067
3922
  { key: "team", label: "Team", maxWidth: 18 },
@@ -2092,18 +3947,15 @@ var init_stats = __esm({
2092
3947
  full: { type: "boolean", description: "Show all columns in table output" }
2093
3948
  },
2094
3949
  async run({ args }) {
2095
- const season = Number(args.season);
2096
- const round = args.round ? Number(args.round) : void 0;
3950
+ const season = validateSeason(args.season);
3951
+ const round = args.round ? validateRound(args.round) : void 0;
2097
3952
  const matchId = args["match-id"];
3953
+ const source = validateSource(args.source);
3954
+ const competition = validateCompetition(args.competition);
3955
+ const format = validateFormat(args.format);
2098
3956
  const result = await withSpinner(
2099
3957
  "Fetching player stats\u2026",
2100
- () => fetchPlayerStats({
2101
- source: args.source,
2102
- season,
2103
- round,
2104
- matchId,
2105
- competition: args.competition
2106
- })
3958
+ () => fetchPlayerStats({ source, season, round, matchId, competition })
2107
3959
  );
2108
3960
  if (!result.success) {
2109
3961
  throw result.error;
@@ -2115,7 +3967,7 @@ var init_stats = __esm({
2115
3967
  const formatOptions = {
2116
3968
  json: args.json,
2117
3969
  csv: args.csv,
2118
- format: args.format,
3970
+ format,
2119
3971
  full: args.full,
2120
3972
  columns: DEFAULT_COLUMNS2
2121
3973
  };
@@ -2138,6 +3990,7 @@ var init_fixture2 = __esm({
2138
3990
  init_index();
2139
3991
  init_formatters();
2140
3992
  init_ui();
3993
+ init_validation2();
2141
3994
  DEFAULT_COLUMNS3 = [
2142
3995
  { key: "roundNumber", label: "Round", maxWidth: 6 },
2143
3996
  { key: "date", label: "Date", maxWidth: 16 },
@@ -2165,16 +4018,14 @@ var init_fixture2 = __esm({
2165
4018
  full: { type: "boolean", description: "Show all columns in table output" }
2166
4019
  },
2167
4020
  async run({ args }) {
2168
- const season = Number(args.season);
2169
- const round = args.round ? Number(args.round) : void 0;
4021
+ const season = validateSeason(args.season);
4022
+ const round = args.round ? validateRound(args.round) : void 0;
4023
+ const source = validateSource(args.source);
4024
+ const competition = validateCompetition(args.competition);
4025
+ const format = validateFormat(args.format);
2170
4026
  const result = await withSpinner(
2171
4027
  "Fetching fixture\u2026",
2172
- () => fetchFixture({
2173
- source: args.source,
2174
- season,
2175
- round,
2176
- competition: args.competition
2177
- })
4028
+ () => fetchFixture({ source, season, round, competition })
2178
4029
  );
2179
4030
  if (!result.success) {
2180
4031
  throw result.error;
@@ -2184,7 +4035,7 @@ var init_fixture2 = __esm({
2184
4035
  const formatOptions = {
2185
4036
  json: args.json,
2186
4037
  csv: args.csv,
2187
- format: args.format,
4038
+ format,
2188
4039
  full: args.full,
2189
4040
  columns: DEFAULT_COLUMNS3
2190
4041
  };
@@ -2207,6 +4058,7 @@ var init_ladder3 = __esm({
2207
4058
  init_index();
2208
4059
  init_formatters();
2209
4060
  init_ui();
4061
+ init_validation2();
2210
4062
  DEFAULT_COLUMNS4 = [
2211
4063
  { key: "position", label: "Pos", maxWidth: 4 },
2212
4064
  { key: "team", label: "Team", maxWidth: 24 },
@@ -2236,16 +4088,14 @@ var init_ladder3 = __esm({
2236
4088
  full: { type: "boolean", description: "Show all columns in table output" }
2237
4089
  },
2238
4090
  async run({ args }) {
2239
- const season = Number(args.season);
2240
- const round = args.round ? Number(args.round) : void 0;
4091
+ const season = validateSeason(args.season);
4092
+ const round = args.round ? validateRound(args.round) : void 0;
4093
+ const source = validateSource(args.source);
4094
+ const competition = validateCompetition(args.competition);
4095
+ const format = validateFormat(args.format);
2241
4096
  const result = await withSpinner(
2242
4097
  "Fetching ladder\u2026",
2243
- () => fetchLadder({
2244
- source: args.source,
2245
- season,
2246
- round,
2247
- competition: args.competition
2248
- })
4098
+ () => fetchLadder({ source, season, round, competition })
2249
4099
  );
2250
4100
  if (!result.success) {
2251
4101
  throw result.error;
@@ -2257,7 +4107,7 @@ var init_ladder3 = __esm({
2257
4107
  const formatOptions = {
2258
4108
  json: args.json,
2259
4109
  csv: args.csv,
2260
- format: args.format,
4110
+ format,
2261
4111
  full: args.full,
2262
4112
  columns: DEFAULT_COLUMNS4
2263
4113
  };
@@ -2273,6 +4123,28 @@ __export(lineup_exports, {
2273
4123
  lineupCommand: () => lineupCommand
2274
4124
  });
2275
4125
  import { defineCommand as defineCommand5 } from "citty";
4126
+ function flattenLineups(lineups) {
4127
+ const rows = [];
4128
+ for (const lineup of lineups) {
4129
+ for (const { players, team } of [
4130
+ { players: lineup.homePlayers, team: lineup.homeTeam },
4131
+ { players: lineup.awayPlayers, team: lineup.awayTeam }
4132
+ ]) {
4133
+ for (const p of players) {
4134
+ rows.push({
4135
+ matchId: lineup.matchId,
4136
+ team,
4137
+ displayName: p.displayName,
4138
+ jumperNumber: p.jumperNumber,
4139
+ position: p.position,
4140
+ isEmergency: p.isEmergency,
4141
+ isSubstitute: p.isSubstitute
4142
+ });
4143
+ }
4144
+ }
4145
+ }
4146
+ return rows;
4147
+ }
2276
4148
  var DEFAULT_COLUMNS5, lineupCommand;
2277
4149
  var init_lineup3 = __esm({
2278
4150
  "src/cli/commands/lineup.ts"() {
@@ -2280,10 +4152,13 @@ var init_lineup3 = __esm({
2280
4152
  init_index();
2281
4153
  init_formatters();
2282
4154
  init_ui();
4155
+ init_validation2();
2283
4156
  DEFAULT_COLUMNS5 = [
2284
- { key: "matchId", label: "Match", maxWidth: 12 },
2285
- { key: "homeTeam", label: "Home", maxWidth: 20 },
2286
- { key: "awayTeam", label: "Away", maxWidth: 20 }
4157
+ { key: "matchId", label: "Match", maxWidth: 14 },
4158
+ { key: "team", label: "Team", maxWidth: 20 },
4159
+ { key: "displayName", label: "Player", maxWidth: 24 },
4160
+ { key: "jumperNumber", label: "#", maxWidth: 4 },
4161
+ { key: "position", label: "Pos", maxWidth: 12 }
2287
4162
  ];
2288
4163
  lineupCommand = defineCommand5({
2289
4164
  meta: {
@@ -2306,18 +4181,15 @@ var init_lineup3 = __esm({
2306
4181
  full: { type: "boolean", description: "Show all columns in table output" }
2307
4182
  },
2308
4183
  async run({ args }) {
2309
- const season = Number(args.season);
2310
- const round = Number(args.round);
4184
+ const season = validateSeason(args.season);
4185
+ const round = validateRound(args.round);
2311
4186
  const matchId = args["match-id"];
4187
+ const source = validateSource(args.source);
4188
+ const competition = validateCompetition(args.competition);
4189
+ const format = validateFormat(args.format);
2312
4190
  const result = await withSpinner(
2313
4191
  "Fetching lineups\u2026",
2314
- () => fetchLineup({
2315
- source: args.source,
2316
- season,
2317
- round,
2318
- matchId,
2319
- competition: args.competition
2320
- })
4192
+ () => fetchLineup({ source, season, round, matchId, competition })
2321
4193
  );
2322
4194
  if (!result.success) {
2323
4195
  throw result.error;
@@ -2327,11 +4199,16 @@ var init_lineup3 = __esm({
2327
4199
  const formatOptions = {
2328
4200
  json: args.json,
2329
4201
  csv: args.csv,
2330
- format: args.format,
4202
+ format,
2331
4203
  full: args.full,
2332
4204
  columns: DEFAULT_COLUMNS5
2333
4205
  };
2334
- console.log(formatOutput(data, formatOptions));
4206
+ const resolvedFormat = resolveFormat(formatOptions);
4207
+ if (resolvedFormat === "json") {
4208
+ console.log(formatOutput(data, formatOptions));
4209
+ } else {
4210
+ console.log(formatOutput(flattenLineups(data), formatOptions));
4211
+ }
2335
4212
  }
2336
4213
  });
2337
4214
  }
@@ -2350,6 +4227,7 @@ var init_squad = __esm({
2350
4227
  init_index();
2351
4228
  init_formatters();
2352
4229
  init_ui();
4230
+ init_validation2();
2353
4231
  DEFAULT_COLUMNS6 = [
2354
4232
  { key: "displayName", label: "Player", maxWidth: 24 },
2355
4233
  { key: "jumperNumber", label: "#", maxWidth: 4 },
@@ -2363,7 +4241,11 @@ var init_squad = __esm({
2363
4241
  description: "Fetch team squad for a season"
2364
4242
  },
2365
4243
  args: {
2366
- "team-id": { type: "string", description: "Team ID", required: true },
4244
+ "team-id": {
4245
+ type: "string",
4246
+ description: "Team ID, abbreviation, or name (e.g. 5, CARL, Carlton)",
4247
+ required: true
4248
+ },
2367
4249
  season: { type: "string", description: "Season year (e.g. 2025)", required: true },
2368
4250
  competition: {
2369
4251
  type: "string",
@@ -2376,15 +4258,21 @@ var init_squad = __esm({
2376
4258
  full: { type: "boolean", description: "Show all columns in table output" }
2377
4259
  },
2378
4260
  async run({ args }) {
2379
- const teamId = args["team-id"];
2380
- const season = Number(args.season);
4261
+ const season = validateSeason(args.season);
4262
+ const competition = validateCompetition(args.competition);
4263
+ const format = validateFormat(args.format);
4264
+ let teamId = args["team-id"].trim();
4265
+ const isNumeric = /^\d+$/.test(teamId);
4266
+ if (!isNumeric) {
4267
+ const teamsResult = await withSpinner("Resolving team\u2026", () => fetchTeams({ competition }));
4268
+ if (!teamsResult.success) {
4269
+ throw teamsResult.error;
4270
+ }
4271
+ teamId = resolveTeamIdentifier(args["team-id"], teamsResult.data);
4272
+ }
2381
4273
  const result = await withSpinner(
2382
4274
  "Fetching squad\u2026",
2383
- () => fetchSquad({
2384
- teamId,
2385
- season,
2386
- competition: args.competition
2387
- })
4275
+ () => fetchSquad({ teamId, season, competition })
2388
4276
  );
2389
4277
  if (!result.success) {
2390
4278
  throw result.error;
@@ -2394,7 +4282,7 @@ var init_squad = __esm({
2394
4282
  const formatOptions = {
2395
4283
  json: args.json,
2396
4284
  csv: args.csv,
2397
- format: args.format,
4285
+ format,
2398
4286
  full: args.full,
2399
4287
  columns: DEFAULT_COLUMNS6
2400
4288
  };
@@ -2417,6 +4305,7 @@ var init_teams2 = __esm({
2417
4305
  init_index();
2418
4306
  init_formatters();
2419
4307
  init_ui();
4308
+ init_validation2();
2420
4309
  DEFAULT_COLUMNS7 = [
2421
4310
  { key: "teamId", label: "ID", maxWidth: 8 },
2422
4311
  { key: "name", label: "Team", maxWidth: 24 },
@@ -2437,22 +4326,26 @@ var init_teams2 = __esm({
2437
4326
  full: { type: "boolean", description: "Show all columns in table output" }
2438
4327
  },
2439
4328
  async run({ args }) {
4329
+ const competition = validateOptionalCompetition(args.competition);
4330
+ const format = validateFormat(args.format);
2440
4331
  const result = await withSpinner(
2441
4332
  "Fetching teams\u2026",
2442
- () => fetchTeams({
2443
- competition: args.competition,
2444
- teamType: args["team-type"]
2445
- })
4333
+ () => fetchTeams({ competition, teamType: args["team-type"] })
2446
4334
  );
2447
4335
  if (!result.success) {
2448
4336
  throw result.error;
2449
4337
  }
2450
4338
  const data = result.data;
4339
+ if (data.length === 0 && args["team-type"]) {
4340
+ console.error(
4341
+ `No teams found for team type "${args["team-type"]}". Try running without --team-type to see available teams.`
4342
+ );
4343
+ }
2451
4344
  showSummary(`Loaded ${data.length} teams`);
2452
4345
  const formatOptions = {
2453
4346
  json: args.json,
2454
4347
  csv: args.csv,
2455
- format: args.format,
4348
+ format,
2456
4349
  full: args.full,
2457
4350
  columns: DEFAULT_COLUMNS7
2458
4351
  };
@@ -2462,48 +4355,276 @@ var init_teams2 = __esm({
2462
4355
  }
2463
4356
  });
2464
4357
 
2465
- // src/cli.ts
2466
- init_errors();
2467
- import { defineCommand as defineCommand8, runMain } from "citty";
2468
- import pc2 from "picocolors";
2469
- var main = defineCommand8({
2470
- meta: {
2471
- name: "fitzroy",
2472
- version: "1.0.2",
2473
- description: "CLI for fetching AFL data \u2014 match results, player stats, fixtures, ladders, and more"
2474
- },
2475
- subCommands: {
2476
- matches: () => Promise.resolve().then(() => (init_matches(), matches_exports)).then((m) => m.matchesCommand),
2477
- stats: () => Promise.resolve().then(() => (init_stats(), stats_exports)).then((m) => m.statsCommand),
2478
- fixture: () => Promise.resolve().then(() => (init_fixture2(), fixture_exports)).then((m) => m.fixtureCommand),
2479
- ladder: () => Promise.resolve().then(() => (init_ladder3(), ladder_exports)).then((m) => m.ladderCommand),
2480
- lineup: () => Promise.resolve().then(() => (init_lineup3(), lineup_exports)).then((m) => m.lineupCommand),
2481
- squad: () => Promise.resolve().then(() => (init_squad(), squad_exports)).then((m) => m.squadCommand),
2482
- teams: () => Promise.resolve().then(() => (init_teams2(), teams_exports)).then((m) => m.teamsCommand)
4358
+ // src/cli/commands/team-stats.ts
4359
+ var team_stats_exports = {};
4360
+ __export(team_stats_exports, {
4361
+ teamStatsCommand: () => teamStatsCommand
4362
+ });
4363
+ import { defineCommand as defineCommand8 } from "citty";
4364
+ function flattenEntries(data) {
4365
+ return data.map((entry) => {
4366
+ const { stats, ...rest } = entry;
4367
+ return { ...rest, ...stats };
4368
+ });
4369
+ }
4370
+ var DEFAULT_COLUMNS8, teamStatsCommand;
4371
+ var init_team_stats2 = __esm({
4372
+ "src/cli/commands/team-stats.ts"() {
4373
+ "use strict";
4374
+ init_index();
4375
+ init_formatters();
4376
+ init_ui();
4377
+ init_validation2();
4378
+ DEFAULT_COLUMNS8 = [
4379
+ { key: "team", label: "Team", maxWidth: 24 },
4380
+ { key: "gamesPlayed", label: "GP", maxWidth: 5 },
4381
+ { key: "K", label: "K", maxWidth: 6 },
4382
+ { key: "HB", label: "HB", maxWidth: 6 },
4383
+ { key: "D", label: "D", maxWidth: 6 },
4384
+ { key: "M", label: "M", maxWidth: 6 },
4385
+ { key: "G", label: "G", maxWidth: 6 },
4386
+ { key: "B", label: "B", maxWidth: 6 },
4387
+ { key: "T", label: "T", maxWidth: 6 },
4388
+ { key: "I50", label: "I50", maxWidth: 6 }
4389
+ ];
4390
+ teamStatsCommand = defineCommand8({
4391
+ meta: {
4392
+ name: "team-stats",
4393
+ description: "Fetch team aggregate statistics for a season"
4394
+ },
4395
+ args: {
4396
+ season: { type: "string", description: "Season year (e.g. 2024)", required: true },
4397
+ source: {
4398
+ type: "string",
4399
+ description: "Data source (footywire, afl-tables)",
4400
+ default: "footywire"
4401
+ },
4402
+ summary: { type: "string", description: "Summary type: totals or averages", default: "totals" },
4403
+ json: { type: "boolean", description: "Output as JSON" },
4404
+ csv: { type: "boolean", description: "Output as CSV" },
4405
+ format: { type: "string", description: "Output format: table, json, csv" },
4406
+ full: { type: "boolean", description: "Show all columns in table output" }
4407
+ },
4408
+ async run({ args }) {
4409
+ const season = validateSeason(args.season);
4410
+ const source = validateSource(args.source);
4411
+ const format = validateFormat(args.format);
4412
+ const summaryType = args.summary;
4413
+ const result = await withSpinner(
4414
+ "Fetching team stats\u2026",
4415
+ () => fetchTeamStats({ source, season, summaryType })
4416
+ );
4417
+ if (!result.success) {
4418
+ throw result.error;
4419
+ }
4420
+ const data = result.data;
4421
+ showSummary(`Loaded stats for ${data.length} teams (${season}, ${summaryType})`);
4422
+ const flat = flattenEntries(data);
4423
+ const formatOptions = {
4424
+ json: args.json,
4425
+ csv: args.csv,
4426
+ format,
4427
+ full: args.full,
4428
+ columns: DEFAULT_COLUMNS8
4429
+ };
4430
+ console.log(formatOutput(flat, formatOptions));
4431
+ }
4432
+ });
4433
+ }
4434
+ });
4435
+
4436
+ // src/cli/commands/player-details.ts
4437
+ var player_details_exports = {};
4438
+ __export(player_details_exports, {
4439
+ playerDetailsCommand: () => playerDetailsCommand
4440
+ });
4441
+ import { defineCommand as defineCommand9 } from "citty";
4442
+ var DEFAULT_COLUMNS9, playerDetailsCommand;
4443
+ var init_player_details2 = __esm({
4444
+ "src/cli/commands/player-details.ts"() {
4445
+ "use strict";
4446
+ init_index();
4447
+ init_formatters();
4448
+ init_ui();
4449
+ init_validation2();
4450
+ DEFAULT_COLUMNS9 = [
4451
+ { key: "displayName", label: "Player", maxWidth: 24 },
4452
+ { key: "jumperNumber", label: "#", maxWidth: 4 },
4453
+ { key: "position", label: "Pos", maxWidth: 12 },
4454
+ { key: "heightCm", label: "Ht", maxWidth: 5 },
4455
+ { key: "weightKg", label: "Wt", maxWidth: 5 },
4456
+ { key: "gamesPlayed", label: "Games", maxWidth: 6 },
4457
+ { key: "dateOfBirth", label: "DOB", maxWidth: 12 }
4458
+ ];
4459
+ playerDetailsCommand = defineCommand9({
4460
+ meta: {
4461
+ name: "player-details",
4462
+ description: "Fetch player biographical details for a team"
4463
+ },
4464
+ args: {
4465
+ team: { type: "positional", description: "Team name (e.g. Carlton, Hawthorn)", required: true },
4466
+ source: {
4467
+ type: "string",
4468
+ description: "Data source: afl-api, footywire, afl-tables",
4469
+ default: "afl-api"
4470
+ },
4471
+ season: { type: "string", description: "Season year (for AFL API source, e.g. 2025)" },
4472
+ competition: {
4473
+ type: "string",
4474
+ description: "Competition code (AFLM or AFLW)",
4475
+ default: "AFLM"
4476
+ },
4477
+ json: { type: "boolean", description: "Output as JSON" },
4478
+ csv: { type: "boolean", description: "Output as CSV" },
4479
+ format: { type: "string", description: "Output format: table, json, csv" },
4480
+ full: { type: "boolean", description: "Show all columns in table output" }
4481
+ },
4482
+ async run({ args }) {
4483
+ const source = validateSource(args.source);
4484
+ const competition = validateCompetition(args.competition);
4485
+ const format = validateFormat(args.format);
4486
+ const season = validateOptionalSeason(args.season) ?? resolveDefaultSeason(competition);
4487
+ const result = await withSpinner(
4488
+ "Fetching player details\u2026",
4489
+ () => fetchPlayerDetails({ source, team: args.team, season, competition })
4490
+ );
4491
+ if (!result.success) {
4492
+ throw result.error;
4493
+ }
4494
+ const data = result.data;
4495
+ showSummary(`Loaded ${data.length} players for ${args.team} (${source})`);
4496
+ const formatOptions = {
4497
+ json: args.json,
4498
+ csv: args.csv,
4499
+ format,
4500
+ full: args.full,
4501
+ columns: DEFAULT_COLUMNS9
4502
+ };
4503
+ console.log(formatOutput(data, formatOptions));
4504
+ }
4505
+ });
4506
+ }
4507
+ });
4508
+
4509
+ // src/cli/commands/coaches-votes.ts
4510
+ var coaches_votes_exports = {};
4511
+ __export(coaches_votes_exports, {
4512
+ coachesVotesCommand: () => coachesVotesCommand
4513
+ });
4514
+ import { defineCommand as defineCommand10 } from "citty";
4515
+ var DEFAULT_COLUMNS10, coachesVotesCommand;
4516
+ var init_coaches_votes2 = __esm({
4517
+ "src/cli/commands/coaches-votes.ts"() {
4518
+ "use strict";
4519
+ init_index();
4520
+ init_formatters();
4521
+ init_ui();
4522
+ init_validation2();
4523
+ DEFAULT_COLUMNS10 = [
4524
+ { key: "season", label: "Season", maxWidth: 8 },
4525
+ { key: "round", label: "Round", maxWidth: 6 },
4526
+ { key: "homeTeam", label: "Home", maxWidth: 20 },
4527
+ { key: "awayTeam", label: "Away", maxWidth: 20 },
4528
+ { key: "playerName", label: "Player", maxWidth: 30 },
4529
+ { key: "votes", label: "Votes", maxWidth: 6 }
4530
+ ];
4531
+ coachesVotesCommand = defineCommand10({
4532
+ meta: {
4533
+ name: "coaches-votes",
4534
+ description: "Fetch AFLCA coaches votes for a season"
4535
+ },
4536
+ args: {
4537
+ season: { type: "string", description: "Season year (e.g. 2024)", required: true },
4538
+ round: { type: "string", description: "Round number" },
4539
+ competition: {
4540
+ type: "string",
4541
+ description: "Competition code (AFLM or AFLW)",
4542
+ default: "AFLM"
4543
+ },
4544
+ team: { type: "string", description: "Filter by team name" },
4545
+ json: { type: "boolean", description: "Output as JSON" },
4546
+ csv: { type: "boolean", description: "Output as CSV" },
4547
+ format: { type: "string", description: "Output format: table, json, csv" },
4548
+ full: { type: "boolean", description: "Show all columns in table output" }
4549
+ },
4550
+ async run({ args }) {
4551
+ const season = validateSeason(args.season);
4552
+ const round = args.round ? validateRound(args.round) : void 0;
4553
+ const competition = validateCompetition(args.competition);
4554
+ const format = validateFormat(args.format);
4555
+ const result = await withSpinner(
4556
+ "Fetching coaches votes\u2026",
4557
+ () => fetchCoachesVotes({ season, round, competition, team: args.team })
4558
+ );
4559
+ if (!result.success) {
4560
+ throw result.error;
4561
+ }
4562
+ const data = result.data;
4563
+ const teamSuffix = args.team ? ` for ${args.team}` : "";
4564
+ const roundSuffix = round ? ` round ${round}` : "";
4565
+ showSummary(`Loaded ${data.length} vote records for ${season}${roundSuffix}${teamSuffix}`);
4566
+ const formatOptions = {
4567
+ json: args.json,
4568
+ csv: args.csv,
4569
+ format,
4570
+ full: args.full,
4571
+ columns: DEFAULT_COLUMNS10
4572
+ };
4573
+ console.log(formatOutput(data, formatOptions));
4574
+ }
4575
+ });
2483
4576
  }
2484
4577
  });
4578
+
4579
+ // src/cli.ts
4580
+ import { defineCommand as defineCommand11, runMain } from "citty";
4581
+
4582
+ // src/cli/error-boundary.ts
4583
+ init_errors();
4584
+ import pc from "picocolors";
2485
4585
  function formatError(error) {
2486
4586
  if (error instanceof ValidationError && error.issues) {
2487
- const issueLines = error.issues.map((i) => ` ${pc2.yellow(i.path)}: ${i.message}`);
2488
- return `${pc2.red("Validation error:")}
4587
+ const issueLines = error.issues.map((i) => ` ${pc.yellow(i.path)}: ${i.message}`);
4588
+ return `${pc.red("Validation error:")}
2489
4589
  ${issueLines.join("\n")}`;
2490
4590
  }
2491
4591
  if (error instanceof AflApiError) {
2492
4592
  const status = error.statusCode ? ` (HTTP ${error.statusCode})` : "";
2493
- return `${pc2.red("AFL API error:")} ${error.message}${status}`;
4593
+ return `${pc.red("AFL API error:")} ${error.message}${status}`;
2494
4594
  }
2495
4595
  if (error instanceof ScrapeError) {
2496
4596
  const source = error.source ? ` [${error.source}]` : "";
2497
- return `${pc2.red("Scrape error:")} ${error.message}${source}`;
4597
+ return `${pc.red("Scrape error:")} ${error.message}${source}`;
2498
4598
  }
2499
4599
  if (error instanceof UnsupportedSourceError) {
2500
- return `${pc2.red("Unsupported source:")} ${error.message}`;
4600
+ return `${pc.red("Unsupported source:")} ${error.message}`;
2501
4601
  }
2502
4602
  if (error instanceof Error) {
2503
- return `${pc2.red("Error:")} ${error.message}`;
4603
+ return `${pc.red("Error:")} ${error.message}`;
2504
4604
  }
2505
- return `${pc2.red("Error:")} ${String(error)}`;
4605
+ return `${pc.red("Error:")} ${String(error)}`;
2506
4606
  }
4607
+
4608
+ // src/cli.ts
4609
+ var main = defineCommand11({
4610
+ meta: {
4611
+ name: "fitzroy",
4612
+ version: "1.1.1",
4613
+ description: "CLI for fetching AFL data \u2014 match results, player stats, fixtures, ladders, and more"
4614
+ },
4615
+ subCommands: {
4616
+ matches: () => Promise.resolve().then(() => (init_matches(), matches_exports)).then((m) => m.matchesCommand),
4617
+ stats: () => Promise.resolve().then(() => (init_stats(), stats_exports)).then((m) => m.statsCommand),
4618
+ fixture: () => Promise.resolve().then(() => (init_fixture2(), fixture_exports)).then((m) => m.fixtureCommand),
4619
+ ladder: () => Promise.resolve().then(() => (init_ladder3(), ladder_exports)).then((m) => m.ladderCommand),
4620
+ lineup: () => Promise.resolve().then(() => (init_lineup3(), lineup_exports)).then((m) => m.lineupCommand),
4621
+ squad: () => Promise.resolve().then(() => (init_squad(), squad_exports)).then((m) => m.squadCommand),
4622
+ teams: () => Promise.resolve().then(() => (init_teams2(), teams_exports)).then((m) => m.teamsCommand),
4623
+ "team-stats": () => Promise.resolve().then(() => (init_team_stats2(), team_stats_exports)).then((m) => m.teamStatsCommand),
4624
+ "player-details": () => Promise.resolve().then(() => (init_player_details2(), player_details_exports)).then((m) => m.playerDetailsCommand),
4625
+ "coaches-votes": () => Promise.resolve().then(() => (init_coaches_votes2(), coaches_votes_exports)).then((m) => m.coachesVotesCommand)
4626
+ }
4627
+ });
2507
4628
  runMain(main).catch((error) => {
2508
4629
  console.error(formatError(error));
2509
4630
  process.exit(1);