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/CHANGELOG.md +9 -0
- package/README.md +190 -72
- package/dist/cache.js +0 -3
- package/dist/cli.js +141 -38
- package/dist/client.d.ts +1 -6
- package/dist/client.js +0 -10
- package/dist/credentials.d.ts +91 -0
- package/dist/credentials.js +145 -0
- package/dist/db/sqlite-store.d.ts +1 -1
- package/dist/db/sqlite-store.js +0 -36
- package/dist/fantasy.js +0 -4
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -3
- package/dist/sources/create.d.ts +16 -0
- package/dist/sources/create.js +23 -0
- package/dist/sources/fbref.d.ts +3 -0
- package/dist/sources/fbref.js +3 -0
- package/dist/sources/index.d.ts +6 -0
- package/dist/sources/index.js +2 -0
- package/dist/sources/statsbomb-endpoints.d.ts +57 -0
- package/dist/sources/statsbomb-endpoints.js +169 -0
- package/dist/sources/statsbomb.d.ts +59 -9
- package/dist/sources/statsbomb.js +177 -19
- package/dist/sources/types.d.ts +1 -2
- package/dist/types.d.ts +0 -16
- package/package.json +1 -1
- package/dist/injuries.d.ts +0 -12
- package/dist/injuries.js +0 -15
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 {
|
|
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
|
|
43
|
-
--
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
129
|
-
throw new Error(
|
|
169
|
+
if (sourceId === "fbref" && paidExtrasRequested(paidExtras)) {
|
|
170
|
+
throw new Error("Paid extras apply only to --source statsbomb.");
|
|
130
171
|
}
|
|
131
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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);\
|
|
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);
|