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