fitzroy 1.1.0 → 1.2.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/index.js CHANGED
@@ -47,35 +47,40 @@ function parseAflApiDate(iso) {
47
47
  }
48
48
  return date;
49
49
  }
50
- function parseFootyWireDate(dateStr) {
50
+ function parseFootyWireDate(dateStr, defaultYear) {
51
51
  const trimmed = dateStr.trim();
52
52
  if (trimmed === "") {
53
53
  return null;
54
54
  }
55
55
  const withoutDow = trimmed.replace(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+/i, "");
56
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;
57
+ const fullMatch = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(normalised);
58
+ if (fullMatch) {
59
+ const [, dayStr, monthStr, yearStr] = fullMatch;
60
+ if (dayStr && monthStr && yearStr) {
61
+ return buildUtcDate(Number.parseInt(yearStr, 10), monthStr, Number.parseInt(dayStr, 10));
62
+ }
74
63
  }
75
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== monthIndex || date.getUTCDate() !== day) {
76
- return null;
64
+ const shortMatch = /^(\d{1,2})\s+([A-Za-z]+)(?:\s+(\d{1,2}):(\d{2})(am|pm))?$/i.exec(normalised);
65
+ if (shortMatch && defaultYear != null) {
66
+ const [, dayStr, monthStr, hourStr, minStr, ampm] = shortMatch;
67
+ if (!dayStr || !monthStr) return null;
68
+ const monthIndex = MONTH_ABBREV_TO_INDEX.get(monthStr.toLowerCase());
69
+ if (monthIndex === void 0) return null;
70
+ const day = Number.parseInt(dayStr, 10);
71
+ const hasTime = hourStr && minStr && ampm;
72
+ if (!hasTime) {
73
+ return buildUtcDate(defaultYear, monthStr, day);
74
+ }
75
+ let aestHours = Number.parseInt(hourStr, 10);
76
+ const minutes = Number.parseInt(minStr, 10);
77
+ if (ampm.toLowerCase() === "pm" && aestHours < 12) aestHours += 12;
78
+ if (ampm.toLowerCase() === "am" && aestHours === 12) aestHours = 0;
79
+ const date = new Date(Date.UTC(defaultYear, monthIndex, day, aestHours - 10, minutes));
80
+ if (Number.isNaN(date.getTime())) return null;
81
+ return date;
77
82
  }
78
- return date;
83
+ return null;
79
84
  }
80
85
  function parseAflTablesDate(dateStr) {
81
86
  const trimmed = dateStr.trim();
@@ -810,7 +815,7 @@ function parseMatchList(html, year) {
810
815
  const scoreLink = scoreCell.find("a").attr("href") ?? "";
811
816
  const midMatch = /mid=(\d+)/.exec(scoreLink);
812
817
  const matchId = midMatch?.[1] ? `FW_${midMatch[1]}` : `FW_${year}_R${currentRound}_${homeTeam}`;
813
- const date = parseFootyWireDate(dateText) ?? new Date(year, 0, 1);
818
+ const date = parseFootyWireDate(dateText, year) ?? new Date(Date.UTC(year, 0, 1));
814
819
  const homeGoals = Math.floor(homePoints / 6);
815
820
  const homeBehinds = homePoints - homeGoals * 6;
816
821
  const awayGoals = Math.floor(awayPoints / 6);
@@ -880,7 +885,7 @@ function parseFixtureList(html, year) {
880
885
  if (teamLinks.length < 2) return;
881
886
  const homeTeam = normaliseTeamName($(teamLinks[0]).text().trim());
882
887
  const awayTeam = normaliseTeamName($(teamLinks[1]).text().trim());
883
- const date = parseFootyWireDate(dateText) ?? new Date(year, 0, 1);
888
+ const date = parseFootyWireDate(dateText, year) ?? new Date(Date.UTC(year, 0, 1));
884
889
  gameNumber++;
885
890
  const scoreCell = cells.length >= 5 ? $(cells[4]) : null;
886
891
  const scoreText = scoreCell?.text().trim() ?? "";
@@ -988,6 +993,12 @@ var FOOTYWIRE_SLUG_MAP = /* @__PURE__ */ new Map([
988
993
  function teamNameToFootyWireSlug(teamName) {
989
994
  return FOOTYWIRE_SLUG_MAP.get(teamName);
990
995
  }
996
+ function normaliseDob(raw) {
997
+ if (!raw) return null;
998
+ const parsed = parseFootyWireDate(raw);
999
+ if (parsed) return parsed.toISOString().slice(0, 10);
1000
+ return raw;
1001
+ }
991
1002
  function parseFootyWirePlayerList(html, teamName) {
992
1003
  const $ = cheerio2.load(html);
993
1004
  const players = [];
@@ -1029,7 +1040,7 @@ function parseFootyWirePlayerList(html, teamName) {
1029
1040
  team: teamName,
1030
1041
  jumperNumber,
1031
1042
  position: position || null,
1032
- dateOfBirth: dobText || null,
1043
+ dateOfBirth: normaliseDob(dobText),
1033
1044
  heightCm,
1034
1045
  weightKg: null,
1035
1046
  gamesPlayed,
@@ -1396,6 +1407,22 @@ async function fetchCoachesVotes(query) {
1396
1407
  return ok(votes);
1397
1408
  }
1398
1409
 
1410
+ // src/lib/concurrency.ts
1411
+ async function batchedMap(items, fn, options) {
1412
+ const batchSize = options?.batchSize ?? 5;
1413
+ const delayMs = options?.delayMs ?? 0;
1414
+ const results = [];
1415
+ for (let i = 0; i < items.length; i += batchSize) {
1416
+ const batch = items.slice(i, i + batchSize);
1417
+ const batchResults = await Promise.all(batch.map(fn));
1418
+ results.push(...batchResults);
1419
+ if (delayMs > 0 && i + batchSize < items.length) {
1420
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1421
+ }
1422
+ }
1423
+ return results;
1424
+ }
1425
+
1399
1426
  // src/lib/validation.ts
1400
1427
  import { z } from "zod/v4";
1401
1428
  var AflApiTokenSchema = z.object({
@@ -1501,71 +1528,72 @@ var CfsPlayerInnerSchema = z.object({
1501
1528
  captain: z.boolean().optional(),
1502
1529
  playerJumperNumber: z.number().optional()
1503
1530
  }).passthrough();
1531
+ var statNum = z.number().nullable().optional();
1504
1532
  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(),
1533
+ goals: statNum,
1534
+ behinds: statNum,
1535
+ kicks: statNum,
1536
+ handballs: statNum,
1537
+ disposals: statNum,
1538
+ marks: statNum,
1539
+ bounces: statNum,
1540
+ tackles: statNum,
1541
+ contestedPossessions: statNum,
1542
+ uncontestedPossessions: statNum,
1543
+ totalPossessions: statNum,
1544
+ inside50s: statNum,
1545
+ marksInside50: statNum,
1546
+ contestedMarks: statNum,
1547
+ hitouts: statNum,
1548
+ onePercenters: statNum,
1549
+ disposalEfficiency: statNum,
1550
+ clangers: statNum,
1551
+ freesFor: statNum,
1552
+ freesAgainst: statNum,
1553
+ dreamTeamPoints: statNum,
1526
1554
  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(),
1555
+ centreClearances: statNum,
1556
+ stoppageClearances: statNum,
1557
+ totalClearances: statNum
1558
+ }).passthrough().nullable().optional(),
1559
+ rebound50s: statNum,
1560
+ goalAssists: statNum,
1561
+ goalAccuracy: statNum,
1562
+ turnovers: statNum,
1563
+ intercepts: statNum,
1564
+ tacklesInside50: statNum,
1565
+ shotsAtGoal: statNum,
1566
+ metresGained: statNum,
1567
+ scoreInvolvements: statNum,
1568
+ ratingPoints: statNum,
1541
1569
  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()
1570
+ effectiveDisposals: statNum,
1571
+ effectiveKicks: statNum,
1572
+ kickEfficiency: statNum,
1573
+ kickToHandballRatio: statNum,
1574
+ pressureActs: statNum,
1575
+ defHalfPressureActs: statNum,
1576
+ spoils: statNum,
1577
+ hitoutsToAdvantage: statNum,
1578
+ hitoutWinPercentage: statNum,
1579
+ hitoutToAdvantageRate: statNum,
1580
+ groundBallGets: statNum,
1581
+ f50GroundBallGets: statNum,
1582
+ interceptMarks: statNum,
1583
+ marksOnLead: statNum,
1584
+ contestedPossessionRate: statNum,
1585
+ contestOffOneOnOnes: statNum,
1586
+ contestOffWins: statNum,
1587
+ contestOffWinsPercentage: statNum,
1588
+ contestDefOneOnOnes: statNum,
1589
+ contestDefLosses: statNum,
1590
+ contestDefLossPercentage: statNum,
1591
+ centreBounceAttendances: statNum,
1592
+ kickins: statNum,
1593
+ kickinsPlayon: statNum,
1594
+ ruckContests: statNum,
1595
+ scoreLaunches: statNum
1596
+ }).passthrough().nullable().optional()
1569
1597
  }).passthrough();
1570
1598
  var PlayerStatsItemSchema = z.object({
1571
1599
  player: z.object({
@@ -1578,7 +1606,7 @@ var PlayerStatsItemSchema = z.object({
1578
1606
  teamId: z.string(),
1579
1607
  playerStats: z.object({
1580
1608
  stats: PlayerGameStatsSchema,
1581
- timeOnGroundPercentage: z.number().optional()
1609
+ timeOnGroundPercentage: z.number().nullable().optional()
1582
1610
  }).passthrough()
1583
1611
  }).passthrough();
1584
1612
  var PlayerStatsListSchema = z.object({
@@ -1680,6 +1708,7 @@ var AflApiClient = class {
1680
1708
  fetchFn;
1681
1709
  tokenUrl;
1682
1710
  cachedToken = null;
1711
+ pendingAuth = null;
1683
1712
  constructor(options) {
1684
1713
  this.fetchFn = options?.fetchFn ?? globalThis.fetch;
1685
1714
  this.tokenUrl = options?.tokenUrl ?? TOKEN_URL;
@@ -1687,9 +1716,21 @@ var AflApiClient = class {
1687
1716
  /**
1688
1717
  * Authenticate with the WMCTok token endpoint and cache the token.
1689
1718
  *
1719
+ * Concurrent callers share the same in-flight request to avoid
1720
+ * redundant token fetches (thundering herd prevention).
1721
+ *
1690
1722
  * @returns The access token on success, or an error Result.
1691
1723
  */
1692
1724
  async authenticate() {
1725
+ if (this.pendingAuth) {
1726
+ return this.pendingAuth;
1727
+ }
1728
+ this.pendingAuth = this.doAuthenticate().finally(() => {
1729
+ this.pendingAuth = null;
1730
+ });
1731
+ return this.pendingAuth;
1732
+ }
1733
+ async doAuthenticate() {
1693
1734
  try {
1694
1735
  const response = await this.fetchFn(this.tokenUrl, {
1695
1736
  method: "POST",
@@ -1944,7 +1985,7 @@ var AflApiClient = class {
1944
1985
  return roundsResult;
1945
1986
  }
1946
1987
  const providerIds = roundsResult.data.flatMap((r) => r.providerId ? [r.providerId] : []);
1947
- const results = await Promise.all(providerIds.map((id) => this.fetchRoundMatchItems(id)));
1988
+ const results = await batchedMap(providerIds, (id) => this.fetchRoundMatchItems(id));
1948
1989
  const allItems = [];
1949
1990
  for (const result of results) {
1950
1991
  if (!result.success) {
@@ -2278,8 +2319,9 @@ async function fetchFixture(query) {
2278
2319
  const roundProviderIds = roundsResult.data.flatMap(
2279
2320
  (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
2280
2321
  );
2281
- const roundResults = await Promise.all(
2282
- roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
2322
+ const roundResults = await batchedMap(
2323
+ roundProviderIds,
2324
+ (r) => client.fetchRoundMatchItems(r.providerId)
2283
2325
  );
2284
2326
  const fixtures = [];
2285
2327
  for (let i = 0; i < roundResults.length; i++) {
@@ -2696,6 +2738,7 @@ function parseAttendanceFromInfo(text) {
2696
2738
  if (!match?.[1]) return null;
2697
2739
  return Number.parseInt(match[1].replace(/,/g, ""), 10) || null;
2698
2740
  }
2741
+ var GP_HEADERS = /* @__PURE__ */ new Set(["gm", "gp", "p", "mp", "games"]);
2699
2742
  function parseAflTablesTeamStats(html, year) {
2700
2743
  const $ = cheerio6.load(html);
2701
2744
  const teamMap = /* @__PURE__ */ new Map();
@@ -2709,6 +2752,7 @@ function parseAflTablesTeamStats(html, year) {
2709
2752
  $(rows[0]).find("td, th").each((_ci, cell) => {
2710
2753
  headers.push($(cell).text().trim());
2711
2754
  });
2755
+ const gpColIdx = headers.findIndex((h, i) => i > 0 && GP_HEADERS.has(h.toLowerCase()));
2712
2756
  for (let ri = 1; ri < rows.length; ri++) {
2713
2757
  const cells = $(rows[ri]).find("td");
2714
2758
  if (cells.length < 3) continue;
@@ -2721,7 +2765,12 @@ function parseAflTablesTeamStats(html, year) {
2721
2765
  }
2722
2766
  const entry = teamMap.get(teamName);
2723
2767
  if (!entry) continue;
2768
+ if (gpColIdx >= 0 && suffix === "_for") {
2769
+ const gpVal = Number.parseFloat($(cells[gpColIdx]).text().trim().replace(/,/g, "")) || 0;
2770
+ entry.gamesPlayed = gpVal;
2771
+ }
2724
2772
  for (let ci = 1; ci < cells.length; ci++) {
2773
+ if (ci === gpColIdx) continue;
2725
2774
  const header = headers[ci];
2726
2775
  if (!header) continue;
2727
2776
  const value = Number.parseFloat($(cells[ci]).text().trim().replace(/,/g, "")) || 0;
@@ -3023,8 +3072,9 @@ async function fetchLineup(query) {
3023
3072
  if (matchItems.data.length === 0) {
3024
3073
  return err(new AflApiError(`No matches found for round ${query.round}`));
3025
3074
  }
3026
- const rosterResults = await Promise.all(
3027
- matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
3075
+ const rosterResults = await batchedMap(
3076
+ matchItems.data,
3077
+ (item) => client.fetchMatchRoster(item.match.matchId)
3028
3078
  );
3029
3079
  const lineups = [];
3030
3080
  for (const rosterResult of rosterResults) {
@@ -3279,15 +3329,26 @@ async function fetchPlayerStats(query) {
3279
3329
  case "afl-api": {
3280
3330
  const client = new AflApiClient();
3281
3331
  if (query.matchId) {
3282
- const result = await client.fetchPlayerStats(query.matchId);
3283
- if (!result.success) return result;
3332
+ const [rosterResult, statsResult] = await Promise.all([
3333
+ client.fetchMatchRoster(query.matchId),
3334
+ client.fetchPlayerStats(query.matchId)
3335
+ ]);
3336
+ if (!statsResult.success) return statsResult;
3337
+ const teamIdMap2 = /* @__PURE__ */ new Map();
3338
+ if (rosterResult.success) {
3339
+ const match = rosterResult.data.match;
3340
+ teamIdMap2.set(match.homeTeamId, match.homeTeam.name);
3341
+ teamIdMap2.set(match.awayTeamId, match.awayTeam.name);
3342
+ }
3284
3343
  return ok(
3285
3344
  transformPlayerStats(
3286
- result.data,
3345
+ statsResult.data,
3287
3346
  query.matchId,
3288
3347
  query.season,
3289
3348
  query.round ?? 0,
3290
- competition
3349
+ competition,
3350
+ "afl-api",
3351
+ teamIdMap2.size > 0 ? teamIdMap2 : void 0
3291
3352
  )
3292
3353
  );
3293
3354
  }
@@ -3304,8 +3365,9 @@ async function fetchPlayerStats(query) {
3304
3365
  teamIdMap.set(item.match.homeTeamId, item.match.homeTeam.name);
3305
3366
  teamIdMap.set(item.match.awayTeamId, item.match.awayTeam.name);
3306
3367
  }
3307
- const statsResults = await Promise.all(
3308
- matchItemsResult.data.map((item) => client.fetchPlayerStats(item.match.matchId))
3368
+ const statsResults = await batchedMap(
3369
+ matchItemsResult.data,
3370
+ (item) => client.fetchPlayerStats(item.match.matchId)
3309
3371
  );
3310
3372
  const allStats = [];
3311
3373
  for (let i = 0; i < statsResults.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fitzroy",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "TypeScript library and CLI for AFL data — match results, player stats, fixtures, ladders, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",