campus-stats 0.5.0 → 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.
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { cachePath, emptyCache, mergeSyncResult } from "./cache.js";
3
+ import { describeCredentials, resolveCredentials, } from "./credentials.js";
3
4
  import { dataBundleUrl } from "./data-bundle.js";
4
5
  import { readStore, resolveDbPath, resolveStoreMode, runMigrate, writeCacheToStore, writeSyncToStore, } from "./db/store-api.js";
5
6
  import { FANTASY_PLAYER_STATS_LIMIT, listWomenCompetitions, syncFantasyBundle, updateCachedCompetitions, } from "./fantasy.js";
6
7
  import { CampusClient } from "./client.js";
7
- import { INJURIES_STATUS_MESSAGE } from "./injuries.js";
8
8
  import { proposeIdentitiesForCompetition } from "./identity/propose.js";
9
9
  import { setIdentityStatus } from "./identity/store.js";
10
- import { FbrefSource } from "./sources/fbref.js";
10
+ import { createFootballSource, KNOWN_SOURCE_IDS } from "./sources/create.js";
11
+ import { listStatsBombEndpoints, } from "./sources/statsbomb-endpoints.js";
11
12
  import { StatsBombSource } from "./sources/statsbomb.js";
12
13
  let storeMode = "json";
13
14
  let dbPath = ".campus/campus.sqlite";
@@ -20,6 +21,8 @@ Usage:
20
21
  campus update [--with-players] [--player-stats-limit <n>]
21
22
  campus pull [--url <cache.json url>]
22
23
  campus available
24
+ campus credentials
25
+ campus endpoints
23
26
  campus competitions
24
27
  campus seasons --competition <name>
25
28
  campus teams --competition <name>
@@ -29,7 +32,6 @@ Usage:
29
32
  campus lineups [--match <id>] [--team <name>]
30
33
  campus squad --competition <name> --team <name> [--season <name>]
31
34
  campus fantasy-points [--competition <name>] [--player <name>] [--match <id>]
32
- campus injuries
33
35
  campus identities [--status resolved|pending|rejected]
34
36
  campus identities propose --competition <name>
35
37
  campus identities confirm --id <identityId>
@@ -39,8 +41,15 @@ Usage:
39
41
  Options:
40
42
  --fantasy / --all Sync all StatsBomb women's comps (clubs + national teams)
41
43
  --competition <name> Competition display name (e.g. "Liga F")
42
- --source <id> Data source for sync (default: statsbomb)
43
- --with-players StatsBomb only: also sync v1 player match stats
44
+ --source <id> Data source (default: statsbomb). Free or paid if login set.
45
+ --sb-user <email> StatsBomb paid API username (or SB_USERNAME)
46
+ --sb-password <pass> StatsBomb paid API password (or SB_PASSWORD)
47
+ --with-players StatsBomb: also sync lineups/events → v1 player stats
48
+ --with-paid-player-match-stats Paid API probe: official player-match aggregates
49
+ --with-paid-team-match-stats Paid API probe: team-match aggregates
50
+ --with-paid-player-season-stats Paid API probe: player season aggregates
51
+ --with-paid-team-season-stats Paid API probe: team season aggregates
52
+ --with-paid-360 Paid API probe: 360 frames (needs 360 licence)
44
53
  --player-stats-limit <n> Max matches to enrich (default 5; fantasy default 25)
45
54
  --url <url> Override data-bundle URL for pull
46
55
  --season <name> Season label (e.g. "2023/2024")
@@ -55,6 +64,8 @@ Options:
55
64
  --json Force JSON cache (default)
56
65
  --help Show this help
57
66
 
67
+ Sources: ${KNOWN_SOURCE_IDS.join(", ")}. Free by default; StatsBomb paid login → paid API.
68
+ See: campus endpoints · campus credentials
58
69
  Periodic refresh: see docs/cron.md (GitHub Actions schedule + campus pull).
59
70
  `);
60
71
  process.exit(1);
@@ -84,56 +95,144 @@ function requireCompetition(cache, name) {
84
95
  }
85
96
  return chosen;
86
97
  }
98
+ async function resolveCliCredentials(args) {
99
+ return resolveCredentials({
100
+ statsbomb: {
101
+ username: getFlag(args, "--sb-user"),
102
+ password: getFlag(args, "--sb-password"),
103
+ },
104
+ });
105
+ }
106
+ function resolvePaidExtras(args) {
107
+ return {
108
+ playerMatchStats: hasFlag(args, "--with-paid-player-match-stats"),
109
+ teamMatchStats: hasFlag(args, "--with-paid-team-match-stats"),
110
+ playerSeasonStats: hasFlag(args, "--with-paid-player-season-stats"),
111
+ teamSeasonStats: hasFlag(args, "--with-paid-team-season-stats"),
112
+ frames360: hasFlag(args, "--with-paid-360"),
113
+ };
114
+ }
115
+ function paidExtrasRequested(extras) {
116
+ return Boolean(extras.playerMatchStats ||
117
+ extras.teamMatchStats ||
118
+ extras.playerSeasonStats ||
119
+ extras.teamSeasonStats ||
120
+ extras.frames360);
121
+ }
87
122
  async function cmdSync(args) {
88
123
  const fantasy = hasFlag(args, "--fantasy") || hasFlag(args, "--all");
89
124
  const competition = getFlag(args, "--competition");
90
125
  const withPlayers = hasFlag(args, "--with-players");
126
+ const paidExtras = resolvePaidExtras(args);
91
127
  const limitRaw = getFlag(args, "--player-stats-limit");
92
128
  const playerStatsLimit = limitRaw
93
129
  ? Number(limitRaw)
94
130
  : fantasy
95
131
  ? FANTASY_PLAYER_STATS_LIMIT
96
132
  : 5;
133
+ const creds = await resolveCliCredentials(args);
134
+ const sbCredentials = creds.statsbombPaidReady
135
+ ? {
136
+ username: creds.statsbomb.username,
137
+ password: creds.statsbomb.password,
138
+ apiBaseUrl: creds.statsbomb.apiBaseUrl,
139
+ }
140
+ : undefined;
141
+ if (paidExtrasRequested(paidExtras) && !sbCredentials) {
142
+ throw new Error("Paid extras flags need SB_USERNAME / SB_PASSWORD (or --sb-user / --sb-password). " +
143
+ "See docs/statsbomb-endpoints.md.");
144
+ }
97
145
  if (fantasy) {
98
146
  if (competition) {
99
147
  throw new Error("Use either --fantasy/--all or --competition, not both.");
100
148
  }
101
- console.error("Syncing fantasy bundle (all StatsBomb women's competitions)…");
149
+ const mode = sbCredentials ? "paid API" : "Open Data";
150
+ console.error(`Syncing fantasy bundle (all StatsBomb women's competitions, ${mode})…`);
102
151
  const result = await syncFantasyBundle({
103
152
  includePlayerStats: withPlayers,
104
153
  playerStatsLimit,
105
154
  latestSeasonPlayersOnly: true,
155
+ credentials: sbCredentials,
156
+ paidExtras,
106
157
  onProgress: (msg) => console.error(msg),
107
158
  });
108
159
  const cache = await writeSyncToStore(storeMode, dbPath, result);
109
- printSyncSummary("statsbomb", result, cache);
160
+ printSyncSummary(`statsbomb:${sbCredentials ? "paid" : "open-data"}`, result, cache);
110
161
  return;
111
162
  }
112
163
  if (!competition)
113
164
  usage();
114
165
  const sourceId = (getFlag(args, "--source") ?? "statsbomb").toLowerCase();
115
- let source;
116
- if (sourceId === "statsbomb") {
117
- source = new StatsBombSource({
118
- includePlayerStats: withPlayers,
119
- playerStatsLimit,
120
- });
121
- }
122
- else if (sourceId === "fbref") {
123
- if (withPlayers) {
124
- throw new Error("FBref player stats are not available yet (see docs/fbref-player-stats-deferred.md).");
125
- }
126
- source = new FbrefSource();
166
+ if (sourceId === "fbref" && withPlayers) {
167
+ throw new Error("FBref player stats are not available yet (see docs/fbref-player-stats-deferred.md).");
127
168
  }
128
- else {
129
- throw new Error(`Unknown source: "${sourceId}". Use statsbomb or fbref.`);
169
+ if (sourceId === "fbref" && paidExtrasRequested(paidExtras)) {
170
+ throw new Error("Paid extras apply only to --source statsbomb.");
130
171
  }
131
- console.error(`Syncing "${competition}" from ${source.id}…`);
172
+ const source = createFootballSource(sourceId, {
173
+ statsbomb: {
174
+ includePlayerStats: withPlayers,
175
+ playerStatsLimit,
176
+ credentials: sbCredentials,
177
+ paidExtras,
178
+ },
179
+ credentials: sbCredentials,
180
+ });
181
+ const modeLabel = sourceId === "statsbomb"
182
+ ? sbCredentials
183
+ ? "statsbomb:paid"
184
+ : "statsbomb:open-data"
185
+ : source.id;
186
+ console.error(`Syncing "${competition}" from ${modeLabel}…`);
132
187
  const result = await source.syncCompetition(competition);
133
188
  const cache = await writeSyncToStore(storeMode, dbPath, result);
134
- printSyncSummary(source.id, result, cache);
189
+ let paidExtraProbes;
190
+ if (paidExtrasRequested(paidExtras) &&
191
+ source instanceof StatsBombSource &&
192
+ result.competitions[0] &&
193
+ result.seasons[0]) {
194
+ const compNative = Number(result.competitions[0].sources.find((s) => s.source === "statsbomb")?.id);
195
+ const seasonNative = Number(result.seasons[0].sources
196
+ .find((s) => s.source === "statsbomb")
197
+ ?.id.split(":")
198
+ .at(-1));
199
+ const matchNative = result.matches[0]?.sources.find((s) => s.source === "statsbomb")?.id;
200
+ if (Number.isFinite(compNative) && Number.isFinite(seasonNative)) {
201
+ console.error("Probing optional paid endpoints (not yet mapped to schema)…");
202
+ paidExtraProbes = await source.probePaidExtras({
203
+ competitionId: compNative,
204
+ seasonId: seasonNative,
205
+ matchId: matchNative,
206
+ });
207
+ }
208
+ }
209
+ printSyncSummary(modeLabel, result, cache, paidExtraProbes);
210
+ }
211
+ async function cmdCredentials(args) {
212
+ const creds = await resolveCliCredentials(args);
213
+ const described = describeCredentials(creds);
214
+ console.log(JSON.stringify({
215
+ ...described,
216
+ hint: creds.statsbombPaidReady
217
+ ? "StatsBomb paid login detected — sync uses data.statsbombservices.com"
218
+ : "No StatsBomb login — sync uses free Open Data. FBref stays free HTML only.",
219
+ }, null, 2));
220
+ }
221
+ async function cmdEndpoints() {
222
+ console.log(JSON.stringify({
223
+ endpoints: listStatsBombEndpoints().map((e) => ({
224
+ id: e.id,
225
+ name: e.name,
226
+ wiredInCampus: e.wiredInCampus,
227
+ requiresPaidLicense: e.requiresPaidLicense,
228
+ availability: e.availability,
229
+ paidPath: e.paidPath ?? null,
230
+ openDataPath: e.openDataPath ?? null,
231
+ notes: e.notes,
232
+ })),
233
+ }, null, 2));
135
234
  }
136
- function printSyncSummary(sourceId, result, cache) {
235
+ function printSyncSummary(sourceId, result, cache, paidExtraProbes) {
137
236
  console.log(JSON.stringify({
138
237
  source: sourceId,
139
238
  cache: storeMode === "json" ? cachePath() : dbPath,
@@ -143,6 +242,7 @@ function printSyncSummary(sourceId, result, cache) {
143
242
  matches: result.matches.length,
144
243
  players: result.players?.length ?? 0,
145
244
  playerMatchStats: result.playerMatchStats?.length ?? 0,
245
+ paidExtraProbes,
146
246
  totals: {
147
247
  competitions: cache.competitions.length,
148
248
  seasons: cache.seasons.length,
@@ -159,6 +259,14 @@ async function cmdUpdate(args) {
159
259
  const playerStatsLimit = limitRaw
160
260
  ? Number(limitRaw)
161
261
  : FANTASY_PLAYER_STATS_LIMIT;
262
+ const creds = await resolveCliCredentials(args);
263
+ const sbCredentials = creds.statsbombPaidReady
264
+ ? {
265
+ username: creds.statsbomb.username,
266
+ password: creds.statsbomb.password,
267
+ apiBaseUrl: creds.statsbomb.apiBaseUrl,
268
+ }
269
+ : undefined;
162
270
  const before = await readStore(storeMode, dbPath);
163
271
  console.error(before.competitions.length === 0
164
272
  ? "Cache empty — running full fantasy sync…"
@@ -167,10 +275,11 @@ async function cmdUpdate(args) {
167
275
  includePlayerStats: withPlayers,
168
276
  playerStatsLimit,
169
277
  latestSeasonPlayersOnly: true,
278
+ credentials: sbCredentials,
170
279
  onProgress: (msg) => console.error(msg),
171
280
  });
172
281
  const cache = await writeSyncToStore(storeMode, dbPath, result);
173
- printSyncSummary("statsbomb", result, cache);
282
+ printSyncSummary(`statsbomb:${sbCredentials ? "paid" : "open-data"}`, result, cache);
174
283
  }
175
284
  async function cmdPull(args) {
176
285
  const url = getFlag(args, "--url") ?? dataBundleUrl();
@@ -191,7 +300,6 @@ async function cmdPull(args) {
191
300
  players: remote.players ?? [],
192
301
  playerMatchStats: remote.playerMatchStats ?? [],
193
302
  lineups: remote.lineups ?? [],
194
- injuries: remote.injuries ?? [],
195
303
  };
196
304
  const local = await readStore(storeMode, dbPath);
197
305
  const merged = mergeSyncResult(local, {
@@ -202,7 +310,6 @@ async function cmdPull(args) {
202
310
  players: incoming.players,
203
311
  playerMatchStats: incoming.playerMatchStats,
204
312
  lineups: incoming.lineups,
205
- injuries: incoming.injuries,
206
313
  });
207
314
  // Preserve local identities; prefer remote entity payloads via mergeById.
208
315
  merged.identities = local.identities.length
@@ -463,14 +570,6 @@ async function cmdFantasyPoints(args) {
463
570
  breakdown: r.breakdown,
464
571
  })), null, 2));
465
572
  }
466
- async function cmdInjuries() {
467
- const client = CampusClient.fromCache(await readStore(storeMode, dbPath));
468
- console.log(JSON.stringify({
469
- available: client.injuries().available,
470
- message: INJURIES_STATUS_MESSAGE,
471
- records: client.injuries().records,
472
- }, null, 2));
473
- }
474
573
  async function main() {
475
574
  const [, , command, ...args] = process.argv;
476
575
  if (!command || command === "--help" || args.includes("--help"))
@@ -491,6 +590,13 @@ async function main() {
491
590
  case "available":
492
591
  await cmdAvailable();
493
592
  break;
593
+ case "credentials":
594
+ case "auth":
595
+ await cmdCredentials(args);
596
+ break;
597
+ case "endpoints":
598
+ await cmdEndpoints();
599
+ break;
494
600
  case "competitions":
495
601
  await cmdCompetitions();
496
602
  break;
@@ -518,9 +624,6 @@ async function main() {
518
624
  case "fantasy-points":
519
625
  await cmdFantasyPoints(args);
520
626
  break;
521
- case "injuries":
522
- await cmdInjuries();
523
- break;
524
627
  case "identities":
525
628
  await cmdIdentities(args);
526
629
  break;
package/dist/client.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { type FantasySyncOptions } from "./fantasy.js";
7
7
  import { type FantasyPointRow, type FantasyScoringRules } from "./scoring.js";
8
- import type { CampusCache, Competition, InjuryRecord, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "./types.js";
8
+ import type { CampusCache, Competition, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "./types.js";
9
9
  export interface SquadMember {
10
10
  playerId: string;
11
11
  playerName: string;
@@ -80,9 +80,4 @@ export declare class CampusClient {
80
80
  team?: string;
81
81
  rules?: FantasyScoringRules;
82
82
  }): FantasyPointRow[];
83
- injuries(): {
84
- available: boolean;
85
- message: string;
86
- records: InjuryRecord[];
87
- };
88
83
  }
package/dist/client.js CHANGED
@@ -6,7 +6,6 @@
6
6
  import { emptyCache, loadCache, mergeSyncResult, saveCache, cachePath, } from "./cache.js";
7
7
  import { dataBundleUrl } from "./data-bundle.js";
8
8
  import { syncFantasyBundle, updateCachedCompetitions, } from "./fantasy.js";
9
- import { listInjuries, INJURIES_AVAILABLE, INJURIES_STATUS_MESSAGE } from "./injuries.js";
10
9
  import { DEFAULT_FANTASY_RULES, scoreFantasyPoints, } from "./scoring.js";
11
10
  function includesCI(haystack, needle) {
12
11
  return haystack.toLowerCase().includes(needle.toLowerCase());
@@ -45,7 +44,6 @@ export class CampusClient {
45
44
  players: remote.players ?? [],
46
45
  playerMatchStats: remote.playerMatchStats ?? [],
47
46
  lineups: remote.lineups ?? [],
48
- injuries: remote.injuries ?? [],
49
47
  };
50
48
  const base = options.mergeWith ?? emptyCache();
51
49
  const merged = mergeSyncResult(base, {
@@ -56,7 +54,6 @@ export class CampusClient {
56
54
  players: incoming.players,
57
55
  playerMatchStats: incoming.playerMatchStats,
58
56
  lineups: incoming.lineups,
59
- injuries: incoming.injuries,
60
57
  });
61
58
  merged.identities =
62
59
  base.identities.length > 0 ? base.identities : incoming.identities;
@@ -263,11 +260,4 @@ export class CampusClient {
263
260
  rules: filter?.rules ?? DEFAULT_FANTASY_RULES,
264
261
  });
265
262
  }
266
- injuries() {
267
- return {
268
- available: INJURIES_AVAILABLE,
269
- message: INJURIES_STATUS_MESSAGE,
270
- records: listInjuries(this.cache),
271
- };
272
- }
273
263
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Optional credentials so Campus can use free open feeds by default, or a
3
+ * customer's own StatsBomb login for the paid API.
4
+ *
5
+ * Resolution order (highest wins):
6
+ * 1. Explicit overrides (CLI flags / library options)
7
+ * 2. Environment variables
8
+ * 3. Local config file `.campus/config.json` (gitignored under `.campus/`)
9
+ *
10
+ * StatsBomb paid API uses HTTP Basic Auth (same as statsbombpy):
11
+ * SB_USERNAME / SB_PASSWORD
12
+ *
13
+ * FBref / Sports Reference does **not** sell a public API key — free mode is
14
+ * polite HTML only. See docs/byok.md.
15
+ *
16
+ * Never log raw secrets; use `maskSecret` / `describeCredentials`.
17
+ */
18
+ /** StatsBomb customer login (paid API). Matches statsbombpy env names. */
19
+ export declare const SB_USERNAME_ENV = "SB_USERNAME";
20
+ export declare const SB_PASSWORD_ENV = "SB_PASSWORD";
21
+ export declare const CAMPUS_STATSBOMB_USERNAME_ENV = "CAMPUS_STATSBOMB_USERNAME";
22
+ export declare const CAMPUS_STATSBOMB_PASSWORD_ENV = "CAMPUS_STATSBOMB_PASSWORD";
23
+ /** Optional override for the paid StatsBomb API host. */
24
+ export declare const CAMPUS_STATSBOMB_API_BASE_URL_ENV = "CAMPUS_STATSBOMB_API_BASE_URL";
25
+ export declare const DEFAULT_STATSBOMB_API_BASE = "https://data.statsbombservices.com";
26
+ export type CredentialSource = "override" | "env" | "config" | "none";
27
+ export interface StatsBombCredentials {
28
+ username?: string;
29
+ password?: string;
30
+ /** Paid API host (default data.statsbombservices.com). */
31
+ apiBaseUrl?: string;
32
+ }
33
+ export interface CampusConfigFile {
34
+ statsbomb?: {
35
+ username?: string;
36
+ password?: string;
37
+ apiBaseUrl?: string;
38
+ };
39
+ /** @deprecated Prefer statsbomb.username / statsbomb.password. */
40
+ apiKey?: string;
41
+ apiBaseUrl?: string;
42
+ }
43
+ export interface ResolvedCredentials {
44
+ statsbomb: StatsBombCredentials & {
45
+ usernameSource: CredentialSource;
46
+ passwordSource: CredentialSource;
47
+ apiBaseUrlSource: CredentialSource;
48
+ };
49
+ /** True when both username and password are present. */
50
+ statsbombPaidReady: boolean;
51
+ fbref: {
52
+ /** Always false — FBref has no official API-key product. */
53
+ paidApiAvailable: false;
54
+ note: string;
55
+ };
56
+ }
57
+ export interface ResolveCredentialsOptions {
58
+ statsbomb?: StatsBombCredentials;
59
+ configPath?: string;
60
+ configFile?: CampusConfigFile | null;
61
+ env?: NodeJS.ProcessEnv;
62
+ }
63
+ export declare function defaultConfigPath(cwd?: string): string;
64
+ export declare function loadConfigFile(filePath?: string): Promise<CampusConfigFile | null>;
65
+ /**
66
+ * Resolve optional paid credentials. Missing StatsBomb login is fine — free
67
+ * open-data / FBref HTML still work.
68
+ */
69
+ export declare function resolveCredentials(options?: ResolveCredentialsOptions): Promise<ResolvedCredentials>;
70
+ export declare function requireStatsBombPaidLogin(creds: StatsBombCredentials): {
71
+ username: string;
72
+ password: string;
73
+ };
74
+ /** Mask a secret for CLI / logs. */
75
+ export declare function maskSecret(secret: string, visibleTail?: number): string;
76
+ export declare function describeCredentials(creds: ResolvedCredentials): {
77
+ statsbomb: {
78
+ mode: "paid" | "open-data";
79
+ username: string | null;
80
+ usernameSource: CredentialSource;
81
+ passwordConfigured: boolean;
82
+ passwordSource: CredentialSource;
83
+ apiBaseUrl: string;
84
+ apiBaseUrlSource: CredentialSource;
85
+ };
86
+ fbref: ResolvedCredentials["fbref"];
87
+ };
88
+ /** @deprecated Use requireStatsBombPaidLogin / free open-data instead. */
89
+ export declare function requireApiKey(_creds: {
90
+ apiKey?: string;
91
+ }, _purpose?: string): string;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Optional credentials so Campus can use free open feeds by default, or a
3
+ * customer's own StatsBomb login for the paid API.
4
+ *
5
+ * Resolution order (highest wins):
6
+ * 1. Explicit overrides (CLI flags / library options)
7
+ * 2. Environment variables
8
+ * 3. Local config file `.campus/config.json` (gitignored under `.campus/`)
9
+ *
10
+ * StatsBomb paid API uses HTTP Basic Auth (same as statsbombpy):
11
+ * SB_USERNAME / SB_PASSWORD
12
+ *
13
+ * FBref / Sports Reference does **not** sell a public API key — free mode is
14
+ * polite HTML only. See docs/byok.md.
15
+ *
16
+ * Never log raw secrets; use `maskSecret` / `describeCredentials`.
17
+ */
18
+ import { readFile } from "node:fs/promises";
19
+ import path from "node:path";
20
+ import { DEFAULT_CACHE_DIR } from "./cache.js";
21
+ /** StatsBomb customer login (paid API). Matches statsbombpy env names. */
22
+ export const SB_USERNAME_ENV = "SB_USERNAME";
23
+ export const SB_PASSWORD_ENV = "SB_PASSWORD";
24
+ export const CAMPUS_STATSBOMB_USERNAME_ENV = "CAMPUS_STATSBOMB_USERNAME";
25
+ export const CAMPUS_STATSBOMB_PASSWORD_ENV = "CAMPUS_STATSBOMB_PASSWORD";
26
+ /** Optional override for the paid StatsBomb API host. */
27
+ export const CAMPUS_STATSBOMB_API_BASE_URL_ENV = "CAMPUS_STATSBOMB_API_BASE_URL";
28
+ export const DEFAULT_STATSBOMB_API_BASE = "https://data.statsbombservices.com";
29
+ function firstEnv(env, names) {
30
+ for (const name of names) {
31
+ const value = env[name]?.trim();
32
+ if (value)
33
+ return value;
34
+ }
35
+ return undefined;
36
+ }
37
+ function pickField(override, envValue, configValue) {
38
+ if (override?.trim())
39
+ return { value: override.trim(), source: "override" };
40
+ if (envValue)
41
+ return { value: envValue, source: "env" };
42
+ if (configValue?.trim()) {
43
+ return { value: configValue.trim(), source: "config" };
44
+ }
45
+ return { value: undefined, source: "none" };
46
+ }
47
+ export function defaultConfigPath(cwd = process.cwd()) {
48
+ return path.join(cwd, DEFAULT_CACHE_DIR, "config.json");
49
+ }
50
+ export async function loadConfigFile(filePath = defaultConfigPath()) {
51
+ try {
52
+ const raw = await readFile(filePath, "utf8");
53
+ const parsed = JSON.parse(raw);
54
+ if (parsed == null || typeof parsed !== "object")
55
+ return null;
56
+ return parsed;
57
+ }
58
+ catch (err) {
59
+ const code = err.code;
60
+ if (code === "ENOENT")
61
+ return null;
62
+ throw err;
63
+ }
64
+ }
65
+ /**
66
+ * Resolve optional paid credentials. Missing StatsBomb login is fine — free
67
+ * open-data / FBref HTML still work.
68
+ */
69
+ export async function resolveCredentials(options = {}) {
70
+ const env = options.env ?? process.env;
71
+ const config = options.configFile !== undefined
72
+ ? options.configFile
73
+ : await loadConfigFile(options.configPath ?? defaultConfigPath());
74
+ const sb = config?.statsbomb;
75
+ const username = pickField(options.statsbomb?.username, firstEnv(env, [SB_USERNAME_ENV, CAMPUS_STATSBOMB_USERNAME_ENV]), sb?.username);
76
+ const password = pickField(options.statsbomb?.password, firstEnv(env, [SB_PASSWORD_ENV, CAMPUS_STATSBOMB_PASSWORD_ENV]), sb?.password);
77
+ const apiBaseUrl = pickField(options.statsbomb?.apiBaseUrl, firstEnv(env, [CAMPUS_STATSBOMB_API_BASE_URL_ENV]), sb?.apiBaseUrl ?? config?.apiBaseUrl);
78
+ const statsbomb = {
79
+ username: username.value,
80
+ password: password.value,
81
+ apiBaseUrl: apiBaseUrl.value ?? DEFAULT_STATSBOMB_API_BASE,
82
+ usernameSource: username.source,
83
+ passwordSource: password.source,
84
+ apiBaseUrlSource: apiBaseUrl.source === "none" && apiBaseUrl.value === undefined
85
+ ? "none"
86
+ : apiBaseUrl.value
87
+ ? apiBaseUrl.source === "none"
88
+ ? "none"
89
+ : apiBaseUrl.source
90
+ : "none",
91
+ };
92
+ // Default host counts as configured only when user overrode it.
93
+ if (!apiBaseUrl.value) {
94
+ statsbomb.apiBaseUrl = DEFAULT_STATSBOMB_API_BASE;
95
+ statsbomb.apiBaseUrlSource = "none";
96
+ }
97
+ return {
98
+ statsbomb,
99
+ statsbombPaidReady: Boolean(username.value && password.value),
100
+ fbref: {
101
+ paidApiAvailable: false,
102
+ note: "FBref/Sports Reference does not sell a public API key. Campus uses free HTML (rate-limited). For bulk licensing contact Sports Reference directly.",
103
+ },
104
+ };
105
+ }
106
+ export function requireStatsBombPaidLogin(creds) {
107
+ const username = creds.username?.trim();
108
+ const password = creds.password?.trim();
109
+ if (username && password)
110
+ return { username, password };
111
+ throw new Error("StatsBomb paid API needs SB_USERNAME and SB_PASSWORD " +
112
+ "(or CAMPUS_STATSBOMB_USERNAME / CAMPUS_STATSBOMB_PASSWORD, " +
113
+ "or .campus/config.json statsbomb.username/password). " +
114
+ "Without them Campus uses free Open Data.");
115
+ }
116
+ /** Mask a secret for CLI / logs. */
117
+ export function maskSecret(secret, visibleTail = 4) {
118
+ const trimmed = secret.trim();
119
+ if (trimmed.length <= visibleTail)
120
+ return "•".repeat(trimmed.length);
121
+ const head = trimmed.slice(0, Math.min(8, trimmed.length - visibleTail));
122
+ const tail = trimmed.slice(-visibleTail);
123
+ return `${head}${"•".repeat(Math.max(4, trimmed.length - head.length - visibleTail))}${tail}`;
124
+ }
125
+ export function describeCredentials(creds) {
126
+ return {
127
+ statsbomb: {
128
+ mode: creds.statsbombPaidReady ? "paid" : "open-data",
129
+ username: creds.statsbomb.username
130
+ ? maskSecret(creds.statsbomb.username)
131
+ : null,
132
+ usernameSource: creds.statsbomb.usernameSource,
133
+ passwordConfigured: Boolean(creds.statsbomb.password),
134
+ passwordSource: creds.statsbomb.passwordSource,
135
+ apiBaseUrl: creds.statsbomb.apiBaseUrl ?? DEFAULT_STATSBOMB_API_BASE,
136
+ apiBaseUrlSource: creds.statsbomb.apiBaseUrlSource,
137
+ },
138
+ fbref: creds.fbref,
139
+ };
140
+ }
141
+ /** @deprecated Use requireStatsBombPaidLogin / free open-data instead. */
142
+ export function requireApiKey(_creds, _purpose = "paid sources") {
143
+ throw new Error("Generic API keys were replaced by StatsBomb login (SB_USERNAME / SB_PASSWORD). " +
144
+ "See docs/byok.md.");
145
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CampusCache } from "../types.js";
2
- export declare const SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS competitions (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n country TEXT,\n gender TEXT NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS seasons (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n competition_id TEXT NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS teams (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n country TEXT,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS matches (\n id TEXT PRIMARY KEY,\n competition_id TEXT NOT NULL,\n season_id TEXT NOT NULL,\n date TEXT,\n home_team_id TEXT NOT NULL,\n away_team_id TEXT NOT NULL,\n home_score INTEGER,\n away_score INTEGER,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS identities (\n id TEXT PRIMARY KEY,\n kind TEXT NOT NULL,\n name TEXT NOT NULL,\n aliases_json TEXT NOT NULL,\n sources_json TEXT NOT NULL,\n confidence TEXT NOT NULL,\n status TEXT NOT NULL,\n competition_hint TEXT\n);\nCREATE TABLE IF NOT EXISTS players (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n nickname TEXT,\n country TEXT,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS player_match_stats (\n id TEXT PRIMARY KEY,\n match_id TEXT NOT NULL,\n player_id TEXT NOT NULL,\n team_id TEXT NOT NULL,\n minutes INTEGER,\n goals INTEGER NOT NULL,\n assists INTEGER NOT NULL,\n yellow_cards INTEGER NOT NULL,\n red_cards INTEGER NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS lineups (\n id TEXT PRIMARY KEY,\n match_id TEXT NOT NULL,\n team_id TEXT NOT NULL,\n player_id TEXT NOT NULL,\n started INTEGER NOT NULL,\n jersey_number INTEGER,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS injuries (\n id TEXT PRIMARY KEY,\n player_id TEXT NOT NULL,\n team_id TEXT,\n status TEXT NOT NULL,\n description TEXT,\n from_date TEXT,\n to_date TEXT,\n sources_json TEXT NOT NULL\n);\n";
2
+ export declare const SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS competitions (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n country TEXT,\n gender TEXT NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS seasons (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n competition_id TEXT NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS teams (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n country TEXT,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS matches (\n id TEXT PRIMARY KEY,\n competition_id TEXT NOT NULL,\n season_id TEXT NOT NULL,\n date TEXT,\n home_team_id TEXT NOT NULL,\n away_team_id TEXT NOT NULL,\n home_score INTEGER,\n away_score INTEGER,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS identities (\n id TEXT PRIMARY KEY,\n kind TEXT NOT NULL,\n name TEXT NOT NULL,\n aliases_json TEXT NOT NULL,\n sources_json TEXT NOT NULL,\n confidence TEXT NOT NULL,\n status TEXT NOT NULL,\n competition_hint TEXT\n);\nCREATE TABLE IF NOT EXISTS players (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n nickname TEXT,\n country TEXT,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS player_match_stats (\n id TEXT PRIMARY KEY,\n match_id TEXT NOT NULL,\n player_id TEXT NOT NULL,\n team_id TEXT NOT NULL,\n minutes INTEGER,\n goals INTEGER NOT NULL,\n assists INTEGER NOT NULL,\n yellow_cards INTEGER NOT NULL,\n red_cards INTEGER NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS lineups (\n id TEXT PRIMARY KEY,\n match_id TEXT NOT NULL,\n team_id TEXT NOT NULL,\n player_id TEXT NOT NULL,\n started INTEGER NOT NULL,\n jersey_number INTEGER,\n sources_json TEXT NOT NULL\n);\n";
3
3
  export declare class SqliteStore {
4
4
  private readonly db;
5
5
  constructor(dbPath: string);