campus-stats 0.4.3 → 0.6.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.
@@ -1,22 +1,36 @@
1
1
  /**
2
- * StatsBomb Open Data adapter.
2
+ * StatsBomb adapter — free Open Data by default, optional paid API.
3
3
  *
4
- * Upstream: https://github.com/statsbomb/open-data
5
- * Free for research and genuine football-analytics use. If you publish
6
- * analysis built on this data, credit StatsBomb
7
- * (https://statsbomb.com/media-pack/). Pass that requirement downstream.
4
+ * Free (no login):
5
+ * https://github.com/statsbomb/open-data
6
+ * Credit StatsBomb in published analysis (https://statsbomb.com/media-pack/).
8
7
  *
9
- * This adapter only surfaces women's competitions
10
- * (`competition_gender === "female"`).
8
+ * Paid (customer login same as statsbombpy):
9
+ * SB_USERNAME + SB_PASSWORD → https://data.statsbombservices.com
10
+ * Coverage and freshness follow your StatsBomb contract.
11
+ *
12
+ * Women's competitions only (`competition_gender === "female"`).
11
13
  */
14
+ import { DEFAULT_STATSBOMB_API_BASE, } from "../credentials.js";
12
15
  import { aggregateStatsBombPlayerMatch } from "./statsbomb-player-stats.js";
13
16
  import { entityId } from "../ids.js";
17
+ import { statsBombPaidUrls, paidExtrasSummary, } from "./statsbomb-endpoints.js";
14
18
  const SOURCE = "statsbomb";
15
- const BASE = "https://raw.githubusercontent.com/statsbomb/open-data/master/data";
19
+ const OPEN_DATA_BASE = "https://raw.githubusercontent.com/statsbomb/open-data/master/data";
16
20
  function normalizeName(name) {
17
21
  return name.trim().toLowerCase();
18
22
  }
19
- async function defaultFetchJson(url) {
23
+ function asArray(payload) {
24
+ if (payload == null)
25
+ return [];
26
+ if (Array.isArray(payload))
27
+ return payload;
28
+ return Object.values(payload);
29
+ }
30
+ function basicAuthHeader(username, password) {
31
+ return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
32
+ }
33
+ async function defaultOpenFetchJson(url) {
20
34
  const res = await fetch(url);
21
35
  if (!res.ok) {
22
36
  throw new Error(`StatsBomb fetch failed (${res.status}): ${url}`);
@@ -25,19 +39,84 @@ async function defaultFetchJson(url) {
25
39
  }
26
40
  export class StatsBombSource {
27
41
  id = SOURCE;
28
- baseUrl;
42
+ accessMode;
43
+ openDataBaseUrl;
44
+ apiBaseUrl;
45
+ username;
46
+ password;
29
47
  fetchJson;
30
48
  includePlayerStats;
31
49
  playerStatsLimit;
50
+ paidExtras;
32
51
  constructor(options = {}) {
33
- this.baseUrl = options.baseUrl ?? BASE;
34
- this.fetchJson = options.fetchJson ?? defaultFetchJson;
52
+ const username = options.credentials?.username?.trim();
53
+ const password = options.credentials?.password?.trim();
54
+ this.accessMode = username && password ? "paid" : "open-data";
55
+ this.username = username;
56
+ this.password = password;
57
+ this.openDataBaseUrl =
58
+ options.openDataBaseUrl ?? options.baseUrl ?? OPEN_DATA_BASE;
59
+ this.apiBaseUrl = (options.apiBaseUrl ??
60
+ options.credentials?.apiBaseUrl ??
61
+ DEFAULT_STATSBOMB_API_BASE).replace(/\/$/, "");
35
62
  this.includePlayerStats = options.includePlayerStats ?? false;
36
63
  this.playerStatsLimit = options.playerStatsLimit ?? 5;
64
+ this.paidExtras = options.paidExtras ?? {};
65
+ if (options.fetchJson) {
66
+ this.fetchJson = options.fetchJson;
67
+ }
68
+ else if (this.accessMode === "paid") {
69
+ const user = username;
70
+ const pass = password;
71
+ this.fetchJson = async (url) => {
72
+ const res = await fetch(url, {
73
+ headers: {
74
+ accept: "application/json",
75
+ authorization: basicAuthHeader(user, pass),
76
+ "user-agent": "campus-stats (StatsBomb customer BYOK; +https://github.com/Minacava/campus-stats)",
77
+ },
78
+ });
79
+ if (!res.ok) {
80
+ throw new Error(`StatsBomb paid API HTTP ${res.status} for ${url}. ` +
81
+ `Check SB_USERNAME / SB_PASSWORD and that your contract covers this endpoint.`);
82
+ }
83
+ return (await res.json());
84
+ };
85
+ }
86
+ else {
87
+ this.fetchJson = defaultOpenFetchJson;
88
+ }
89
+ }
90
+ paidUrls() {
91
+ return statsBombPaidUrls(this.apiBaseUrl);
92
+ }
93
+ competitionsUrl() {
94
+ if (this.accessMode === "paid") {
95
+ return this.paidUrls().competitions();
96
+ }
97
+ return `${this.openDataBaseUrl}/competitions.json`;
98
+ }
99
+ matchesUrl(competitionId, seasonId) {
100
+ if (this.accessMode === "paid") {
101
+ return this.paidUrls().matches(competitionId, seasonId);
102
+ }
103
+ return `${this.openDataBaseUrl}/matches/${competitionId}/${seasonId}.json`;
104
+ }
105
+ lineupsUrl(matchId) {
106
+ if (this.accessMode === "paid") {
107
+ return this.paidUrls().lineups(matchId);
108
+ }
109
+ return `${this.openDataBaseUrl}/lineups/${matchId}.json`;
110
+ }
111
+ eventsUrl(matchId) {
112
+ if (this.accessMode === "paid") {
113
+ return this.paidUrls().events(matchId);
114
+ }
115
+ return `${this.openDataBaseUrl}/events/${matchId}.json`;
37
116
  }
38
117
  /** Catalogue of women's competitions (clubs + national-team tournaments). */
39
118
  async listWomenCompetitions() {
40
- const all = await this.fetchJson(`${this.baseUrl}/competitions.json`);
119
+ const all = asArray(await this.fetchJson(this.competitionsUrl()));
41
120
  const byId = new Map();
42
121
  for (const row of all) {
43
122
  if (row.competition_gender !== "female")
@@ -59,7 +138,7 @@ export class StatsBombSource {
59
138
  return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
60
139
  }
61
140
  async syncCompetition(competitionName) {
62
- const all = await this.fetchJson(`${this.baseUrl}/competitions.json`);
141
+ const all = asArray(await this.fetchJson(this.competitionsUrl()));
63
142
  const rows = all.filter((c) => c.competition_gender === "female" &&
64
143
  normalizeName(c.competition_name) === normalizeName(competitionName));
65
144
  if (rows.length === 0) {
@@ -68,8 +147,11 @@ export class StatsBombSource {
68
147
  .filter((c) => c.competition_gender === "female")
69
148
  .map((c) => c.competition_name)),
70
149
  ].sort();
71
- throw new Error(`Competition not found in StatsBomb women's data: "${competitionName}". ` +
72
- `Available: ${available.join(", ")}`);
150
+ const modeHint = this.accessMode === "paid"
151
+ ? "StatsBomb paid API (your contract coverage)"
152
+ : "StatsBomb Open Data";
153
+ throw new Error(`Competition not found in ${modeHint}: "${competitionName}". ` +
154
+ `Available: ${available.join(", ") || "(none)"}`);
73
155
  }
74
156
  const competitions = new Map();
75
157
  const seasons = new Map();
@@ -99,7 +181,7 @@ export class StatsBombSource {
99
181
  },
100
182
  ],
101
183
  });
102
- const matchRows = await this.fetchJson(`${this.baseUrl}/matches/${row.competition_id}/${row.season_id}.json`);
184
+ const matchRows = asArray(await this.fetchJson(this.matchesUrl(row.competition_id, row.season_id)));
103
185
  for (const m of matchRows) {
104
186
  const homeId = entityId(SOURCE, "team", m.home_team.home_team_id);
105
187
  const awayId = entityId(SOURCE, "team", m.away_team.away_team_id);
@@ -144,6 +226,82 @@ export class StatsBombSource {
144
226
  }
145
227
  return result;
146
228
  }
229
+ /**
230
+ * Hit optional paid-only endpoints for a sample competition/season/match.
231
+ * Confirms the licence covers them; Campus does not yet map these into the
232
+ * canonical schema (probe only).
233
+ */
234
+ async probePaidExtras(context) {
235
+ const enabled = paidExtrasSummary(this.paidExtras);
236
+ if (enabled.length === 0)
237
+ return [];
238
+ if (this.accessMode !== "paid") {
239
+ throw new Error(`Paid extras (${enabled.join(", ")}) need SB_USERNAME / SB_PASSWORD. ` +
240
+ `See docs/statsbomb-endpoints.md.`);
241
+ }
242
+ const urls = this.paidUrls();
243
+ const jobs = [];
244
+ if (this.paidExtras.playerSeasonStats) {
245
+ jobs.push({
246
+ id: "playerSeasonStats",
247
+ url: urls.playerSeasonStats(context.competitionId, context.seasonId),
248
+ });
249
+ }
250
+ if (this.paidExtras.teamSeasonStats) {
251
+ jobs.push({
252
+ id: "teamSeasonStats",
253
+ url: urls.teamSeasonStats(context.competitionId, context.seasonId),
254
+ });
255
+ }
256
+ if (context.matchId != null) {
257
+ if (this.paidExtras.playerMatchStats) {
258
+ jobs.push({
259
+ id: "playerMatchStats",
260
+ url: urls.playerMatchStats(context.matchId),
261
+ });
262
+ }
263
+ if (this.paidExtras.teamMatchStats) {
264
+ jobs.push({
265
+ id: "teamMatchStats",
266
+ url: urls.teamMatchStats(context.matchId),
267
+ });
268
+ }
269
+ if (this.paidExtras.frames360) {
270
+ jobs.push({
271
+ id: "frames360",
272
+ url: urls.frames360(context.matchId),
273
+ });
274
+ }
275
+ }
276
+ const out = [];
277
+ for (const job of jobs) {
278
+ try {
279
+ const payload = await this.fetchJson(job.url);
280
+ const itemCount = Array.isArray(payload)
281
+ ? payload.length
282
+ : payload && typeof payload === "object"
283
+ ? Object.keys(payload).length
284
+ : undefined;
285
+ out.push({
286
+ id: job.id,
287
+ ok: true,
288
+ url: job.url,
289
+ itemCount,
290
+ mappedToCampusSchema: false,
291
+ });
292
+ }
293
+ catch (err) {
294
+ out.push({
295
+ id: job.id,
296
+ ok: false,
297
+ url: job.url,
298
+ error: err instanceof Error ? err.message : String(err),
299
+ mappedToCampusSchema: false,
300
+ });
301
+ }
302
+ }
303
+ return out;
304
+ }
147
305
  /** Load aggregated v1 player stats + lineups for the given canonical matches. */
148
306
  async loadPlayerStatsForMatches(matches) {
149
307
  const players = new Map();
@@ -153,8 +311,8 @@ export class StatsBombSource {
153
311
  const nativeId = match.sources.find((s) => s.source === SOURCE)?.id;
154
312
  if (!nativeId)
155
313
  continue;
156
- const lineups = await this.fetchJson(`${this.baseUrl}/lineups/${nativeId}.json`);
157
- const events = await this.fetchJson(`${this.baseUrl}/events/${nativeId}.json`);
314
+ const lineups = asArray(await this.fetchJson(this.lineupsUrl(nativeId)));
315
+ const events = asArray(await this.fetchJson(this.eventsUrl(nativeId)));
158
316
  const agg = aggregateStatsBombPlayerMatch(match.id, lineups, events);
159
317
  for (const p of agg.players)
160
318
  players.set(p.id, p);
@@ -1,4 +1,4 @@
1
- import type { Competition, InjuryRecord, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "../types.js";
1
+ import type { Competition, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "../types.js";
2
2
  /** Result of syncing one competition from a provider into canonical entities. */
3
3
  export interface SyncResult {
4
4
  competitions: Competition[];
@@ -8,7 +8,6 @@ export interface SyncResult {
8
8
  players?: Player[];
9
9
  playerMatchStats?: PlayerMatchStats[];
10
10
  lineups?: LineupEntry[];
11
- injuries?: InjuryRecord[];
12
11
  }
13
12
  /**
14
13
  * Provider adapter contract.
package/dist/types.d.ts CHANGED
@@ -84,21 +84,6 @@ export interface LineupEntry {
84
84
  jerseyNumber?: number | null;
85
85
  sources: SourceRef[];
86
86
  }
87
- /**
88
- * Injury / availability record.
89
- * Open StatsBomb data does not include injuries — this shape is reserved for
90
- * future adapters; the CLI currently returns an empty list.
91
- */
92
- export interface InjuryRecord {
93
- id: string;
94
- playerId: string;
95
- teamId?: string;
96
- status: "injured" | "doubtful" | "suspended" | "unknown";
97
- description?: string;
98
- fromDate?: string;
99
- toDate?: string;
100
- sources: SourceRef[];
101
- }
102
87
  /** In-memory / on-disk cache shape (JSON file in v0). */
103
88
  export interface CampusCache {
104
89
  competitions: Competition[];
@@ -110,5 +95,4 @@ export interface CampusCache {
110
95
  players: Player[];
111
96
  playerMatchStats: PlayerMatchStats[];
112
97
  lineups: LineupEntry[];
113
- injuries: InjuryRecord[];
114
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "campus-stats",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "Campus — installable women's football data layer for apps (clubs, national teams, matches, fantasy points)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -41,11 +41,12 @@
41
41
  "cli"
42
42
  ],
43
43
  "repository": {
44
- "url": "https://campus-tawny-mu.vercel.app/"
44
+ "type": "git",
45
+ "url": "git+https://github.com/Minacava/campus-stats.git"
45
46
  },
46
- "homepage": "https://campus-tawny-mu.vercel.app/",
47
+ "homepage": "https://github.com/Minacava/campus-stats#readme",
47
48
  "bugs": {
48
- "url": "https://campus-tawny-mu.vercel.app/"
49
+ "url": "https://github.com/Minacava/campus-stats/issues"
49
50
  },
50
51
  "license": "MIT",
51
52
  "devDependencies": {
@@ -1,12 +0,0 @@
1
- /**
2
- * Injury / availability feed.
3
- *
4
- * StatsBomb Open Data (and our FBref pilot) do not publish injury lists.
5
- * This module keeps a stable API so fantasy apps can call `listInjuries`
6
- * today and plug a future adapter without changing call sites.
7
- */
8
- import type { CampusCache, InjuryRecord } from "./types.js";
9
- export declare const INJURIES_AVAILABLE = false;
10
- export declare const INJURIES_STATUS_MESSAGE: string;
11
- /** Always empty until an injury-capable adapter is added. */
12
- export declare function listInjuries(cache: CampusCache): InjuryRecord[];
package/dist/injuries.js DELETED
@@ -1,15 +0,0 @@
1
- /**
2
- * Injury / availability feed.
3
- *
4
- * StatsBomb Open Data (and our FBref pilot) do not publish injury lists.
5
- * This module keeps a stable API so fantasy apps can call `listInjuries`
6
- * today and plug a future adapter without changing call sites.
7
- */
8
- export const INJURIES_AVAILABLE = false;
9
- export const INJURIES_STATUS_MESSAGE = "Injury feeds are not available from current open sources (StatsBomb / FBref pilot). " +
10
- "campus exposes InjuryRecord + listInjuries() for future adapters; " +
11
- "today the list is always empty.";
12
- /** Always empty until an injury-capable adapter is added. */
13
- export function listInjuries(cache) {
14
- return cache.injuries ?? [];
15
- }