campus-stats 0.4.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 +35 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/cache.d.ts +10 -0
- package/dist/cache.js +69 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +537 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.js +273 -0
- package/dist/data-bundle.d.ts +11 -0
- package/dist/data-bundle.js +17 -0
- package/dist/db/migrate.d.ts +6 -0
- package/dist/db/migrate.js +22 -0
- package/dist/db/sqlite-store.d.ts +12 -0
- package/dist/db/sqlite-store.js +344 -0
- package/dist/db/store-api.d.ts +9 -0
- package/dist/db/store-api.js +70 -0
- package/dist/fantasy.d.ts +36 -0
- package/dist/fantasy.js +152 -0
- package/dist/http.d.ts +25 -0
- package/dist/http.js +63 -0
- package/dist/identity/match.d.ts +22 -0
- package/dist/identity/match.js +106 -0
- package/dist/identity/propose.d.ts +8 -0
- package/dist/identity/propose.js +45 -0
- package/dist/identity/rules.d.ts +8 -0
- package/dist/identity/rules.js +17 -0
- package/dist/identity/store.d.ts +5 -0
- package/dist/identity/store.js +20 -0
- package/dist/identity/types.d.ts +32 -0
- package/dist/identity/types.js +8 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.js +4 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/injuries.d.ts +12 -0
- package/dist/injuries.js +15 -0
- package/dist/refs.d.ts +7 -0
- package/dist/refs.js +1 -0
- package/dist/scoring.d.ts +37 -0
- package/dist/scoring.js +56 -0
- package/dist/sources/fbref-map.d.ts +14 -0
- package/dist/sources/fbref-map.js +56 -0
- package/dist/sources/fbref-parse.d.ts +17 -0
- package/dist/sources/fbref-parse.js +87 -0
- package/dist/sources/fbref.d.ts +27 -0
- package/dist/sources/fbref.js +72 -0
- package/dist/sources/index.d.ts +3 -0
- package/dist/sources/index.js +2 -0
- package/dist/sources/statsbomb-player-stats.d.ts +63 -0
- package/dist/sources/statsbomb-player-stats.js +107 -0
- package/dist/sources/statsbomb.d.ts +50 -0
- package/dist/sources/statsbomb.js +170 -0
- package/dist/sources/types.d.ts +23 -0
- package/dist/sources/types.js +1 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cachePath, emptyCache, mergeSyncResult } from "./cache.js";
|
|
3
|
+
import { dataBundleUrl, GITLAB_PROJECT_ID, } from "./data-bundle.js";
|
|
4
|
+
import { readStore, resolveDbPath, resolveStoreMode, runMigrate, writeCacheToStore, writeSyncToStore, } from "./db/store-api.js";
|
|
5
|
+
import { FANTASY_PLAYER_STATS_LIMIT, listWomenCompetitions, syncFantasyBundle, updateCachedCompetitions, } from "./fantasy.js";
|
|
6
|
+
import { CampusClient } from "./client.js";
|
|
7
|
+
import { INJURIES_STATUS_MESSAGE } from "./injuries.js";
|
|
8
|
+
import { proposeIdentitiesForCompetition } from "./identity/propose.js";
|
|
9
|
+
import { setIdentityStatus } from "./identity/store.js";
|
|
10
|
+
import { FbrefSource } from "./sources/fbref.js";
|
|
11
|
+
import { StatsBombSource } from "./sources/statsbomb.js";
|
|
12
|
+
let storeMode = "json";
|
|
13
|
+
let dbPath = ".campus/campus.sqlite";
|
|
14
|
+
function usage() {
|
|
15
|
+
console.error(`campus — women's football data CLI
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
campus sync --fantasy|--all [--with-players] [--player-stats-limit <n>]
|
|
19
|
+
campus sync --competition <name> [--source statsbomb|fbref] [--with-players] [--player-stats-limit <n>]
|
|
20
|
+
campus update [--with-players] [--player-stats-limit <n>]
|
|
21
|
+
campus pull [--url <cache.json url>]
|
|
22
|
+
campus available
|
|
23
|
+
campus competitions
|
|
24
|
+
campus seasons --competition <name>
|
|
25
|
+
campus teams --competition <name>
|
|
26
|
+
campus matches --competition <name> [--season <name>] [--team <name>]
|
|
27
|
+
campus players [--team <name>] [--name <name>]
|
|
28
|
+
campus player-stats [--competition <name>] [--match <id>] [--player <name>] [--team <name>]
|
|
29
|
+
campus lineups [--match <id>] [--team <name>]
|
|
30
|
+
campus squad --competition <name> --team <name> [--season <name>]
|
|
31
|
+
campus fantasy-points [--competition <name>] [--player <name>] [--match <id>]
|
|
32
|
+
campus injuries
|
|
33
|
+
campus identities [--status resolved|pending|rejected]
|
|
34
|
+
campus identities propose --competition <name>
|
|
35
|
+
campus identities confirm --id <identityId>
|
|
36
|
+
campus identities reject --id <identityId>
|
|
37
|
+
campus migrate [--db <path>]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--fantasy / --all Sync all StatsBomb women's comps (clubs + national teams)
|
|
41
|
+
--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
|
+
--player-stats-limit <n> Max matches to enrich (default 5; fantasy default 25)
|
|
45
|
+
--url <url> Override data-bundle URL for pull
|
|
46
|
+
--season <name> Season label (e.g. "2023/2024")
|
|
47
|
+
--team <name> Team name substring (case-insensitive)
|
|
48
|
+
--player <name> Player name substring
|
|
49
|
+
--name <name> Player name substring (players command)
|
|
50
|
+
--match <id> Match id filter
|
|
51
|
+
--status <status> Filter identities list
|
|
52
|
+
--id <identityId> Identity id for confirm/reject
|
|
53
|
+
--sqlite Use SQLite store (default path .campus/campus.sqlite)
|
|
54
|
+
--db <path> SQLite database path (implies --sqlite)
|
|
55
|
+
--json Force JSON cache (default)
|
|
56
|
+
--help Show this help
|
|
57
|
+
|
|
58
|
+
Periodic refresh: see docs/cron.md (GitLab CI schedule + campus pull).
|
|
59
|
+
`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
function getFlag(args, name) {
|
|
63
|
+
const idx = args.indexOf(name);
|
|
64
|
+
if (idx === -1)
|
|
65
|
+
return undefined;
|
|
66
|
+
const value = args[idx + 1];
|
|
67
|
+
if (!value || value.startsWith("--")) {
|
|
68
|
+
throw new Error(`Missing value for ${name}`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function hasFlag(args, name) {
|
|
73
|
+
return args.includes(name);
|
|
74
|
+
}
|
|
75
|
+
function includesCI(haystack, needle) {
|
|
76
|
+
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
function requireCompetition(cache, name) {
|
|
79
|
+
const competition = cache.competitions.find((c) => includesCI(c.name, name) || c.name.toLowerCase() === name.toLowerCase());
|
|
80
|
+
const exact = cache.competitions.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
81
|
+
const chosen = exact ?? competition;
|
|
82
|
+
if (!chosen) {
|
|
83
|
+
throw new Error(`Competition not in cache: "${name}". Run sync --competition first.`);
|
|
84
|
+
}
|
|
85
|
+
return chosen;
|
|
86
|
+
}
|
|
87
|
+
async function cmdSync(args) {
|
|
88
|
+
const fantasy = hasFlag(args, "--fantasy") || hasFlag(args, "--all");
|
|
89
|
+
const competition = getFlag(args, "--competition");
|
|
90
|
+
const withPlayers = hasFlag(args, "--with-players");
|
|
91
|
+
const limitRaw = getFlag(args, "--player-stats-limit");
|
|
92
|
+
const playerStatsLimit = limitRaw
|
|
93
|
+
? Number(limitRaw)
|
|
94
|
+
: fantasy
|
|
95
|
+
? FANTASY_PLAYER_STATS_LIMIT
|
|
96
|
+
: 5;
|
|
97
|
+
if (fantasy) {
|
|
98
|
+
if (competition) {
|
|
99
|
+
throw new Error("Use either --fantasy/--all or --competition, not both.");
|
|
100
|
+
}
|
|
101
|
+
console.error("Syncing fantasy bundle (all StatsBomb women's competitions)…");
|
|
102
|
+
const result = await syncFantasyBundle({
|
|
103
|
+
includePlayerStats: withPlayers,
|
|
104
|
+
playerStatsLimit,
|
|
105
|
+
latestSeasonPlayersOnly: true,
|
|
106
|
+
onProgress: (msg) => console.error(msg),
|
|
107
|
+
});
|
|
108
|
+
const cache = await writeSyncToStore(storeMode, dbPath, result);
|
|
109
|
+
printSyncSummary("statsbomb", result, cache);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!competition)
|
|
113
|
+
usage();
|
|
114
|
+
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();
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
throw new Error(`Unknown source: "${sourceId}". Use statsbomb or fbref.`);
|
|
130
|
+
}
|
|
131
|
+
console.error(`Syncing "${competition}" from ${source.id}…`);
|
|
132
|
+
const result = await source.syncCompetition(competition);
|
|
133
|
+
const cache = await writeSyncToStore(storeMode, dbPath, result);
|
|
134
|
+
printSyncSummary(source.id, result, cache);
|
|
135
|
+
}
|
|
136
|
+
function printSyncSummary(sourceId, result, cache) {
|
|
137
|
+
console.log(JSON.stringify({
|
|
138
|
+
source: sourceId,
|
|
139
|
+
cache: storeMode === "json" ? cachePath() : dbPath,
|
|
140
|
+
competitions: result.competitions.map((c) => c.name),
|
|
141
|
+
seasons: result.seasons.length,
|
|
142
|
+
teams: result.teams.length,
|
|
143
|
+
matches: result.matches.length,
|
|
144
|
+
players: result.players?.length ?? 0,
|
|
145
|
+
playerMatchStats: result.playerMatchStats?.length ?? 0,
|
|
146
|
+
totals: {
|
|
147
|
+
competitions: cache.competitions.length,
|
|
148
|
+
seasons: cache.seasons.length,
|
|
149
|
+
teams: cache.teams.length,
|
|
150
|
+
matches: cache.matches.length,
|
|
151
|
+
players: cache.players.length,
|
|
152
|
+
playerMatchStats: cache.playerMatchStats.length,
|
|
153
|
+
},
|
|
154
|
+
}, null, 2));
|
|
155
|
+
}
|
|
156
|
+
async function cmdUpdate(args) {
|
|
157
|
+
const withPlayers = hasFlag(args, "--with-players");
|
|
158
|
+
const limitRaw = getFlag(args, "--player-stats-limit");
|
|
159
|
+
const playerStatsLimit = limitRaw
|
|
160
|
+
? Number(limitRaw)
|
|
161
|
+
: FANTASY_PLAYER_STATS_LIMIT;
|
|
162
|
+
const before = await readStore(storeMode, dbPath);
|
|
163
|
+
console.error(before.competitions.length === 0
|
|
164
|
+
? "Cache empty — running full fantasy sync…"
|
|
165
|
+
: `Updating ${before.competitions.length} cached competition(s)…`);
|
|
166
|
+
const result = await updateCachedCompetitions(before, {
|
|
167
|
+
includePlayerStats: withPlayers,
|
|
168
|
+
playerStatsLimit,
|
|
169
|
+
latestSeasonPlayersOnly: true,
|
|
170
|
+
onProgress: (msg) => console.error(msg),
|
|
171
|
+
});
|
|
172
|
+
const cache = await writeSyncToStore(storeMode, dbPath, result);
|
|
173
|
+
printSyncSummary("statsbomb", result, cache);
|
|
174
|
+
}
|
|
175
|
+
async function cmdPull(args) {
|
|
176
|
+
const url = getFlag(args, "--url") ?? dataBundleUrl(GITLAB_PROJECT_ID);
|
|
177
|
+
console.error(`Pulling data bundle from ${url}…`);
|
|
178
|
+
const res = await fetch(url);
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
throw new Error(`Failed to pull data bundle (${res.status}). ` +
|
|
181
|
+
`Ensure the GitLab CI cron has published campus-data/latest (see docs/cron.md).`);
|
|
182
|
+
}
|
|
183
|
+
const remote = (await res.json());
|
|
184
|
+
const incoming = {
|
|
185
|
+
...emptyCache(),
|
|
186
|
+
competitions: remote.competitions ?? [],
|
|
187
|
+
seasons: remote.seasons ?? [],
|
|
188
|
+
teams: remote.teams ?? [],
|
|
189
|
+
matches: remote.matches ?? [],
|
|
190
|
+
identities: remote.identities ?? [],
|
|
191
|
+
players: remote.players ?? [],
|
|
192
|
+
playerMatchStats: remote.playerMatchStats ?? [],
|
|
193
|
+
lineups: remote.lineups ?? [],
|
|
194
|
+
injuries: remote.injuries ?? [],
|
|
195
|
+
};
|
|
196
|
+
const local = await readStore(storeMode, dbPath);
|
|
197
|
+
const merged = mergeSyncResult(local, {
|
|
198
|
+
competitions: incoming.competitions,
|
|
199
|
+
seasons: incoming.seasons,
|
|
200
|
+
teams: incoming.teams,
|
|
201
|
+
matches: incoming.matches,
|
|
202
|
+
players: incoming.players,
|
|
203
|
+
playerMatchStats: incoming.playerMatchStats,
|
|
204
|
+
lineups: incoming.lineups,
|
|
205
|
+
injuries: incoming.injuries,
|
|
206
|
+
});
|
|
207
|
+
// Preserve local identities; prefer remote entity payloads via mergeById.
|
|
208
|
+
merged.identities = local.identities.length
|
|
209
|
+
? local.identities
|
|
210
|
+
: incoming.identities;
|
|
211
|
+
await writeCacheToStore(storeMode, dbPath, merged);
|
|
212
|
+
console.log(JSON.stringify({
|
|
213
|
+
url,
|
|
214
|
+
cache: storeMode === "json" ? cachePath() : dbPath,
|
|
215
|
+
totals: {
|
|
216
|
+
competitions: merged.competitions.length,
|
|
217
|
+
seasons: merged.seasons.length,
|
|
218
|
+
teams: merged.teams.length,
|
|
219
|
+
matches: merged.matches.length,
|
|
220
|
+
players: merged.players.length,
|
|
221
|
+
playerMatchStats: merged.playerMatchStats.length,
|
|
222
|
+
lineups: merged.lineups.length,
|
|
223
|
+
},
|
|
224
|
+
}, null, 2));
|
|
225
|
+
}
|
|
226
|
+
async function cmdAvailable() {
|
|
227
|
+
const list = await listWomenCompetitions();
|
|
228
|
+
console.log(JSON.stringify(list.map((c) => ({
|
|
229
|
+
id: c.id,
|
|
230
|
+
name: c.name,
|
|
231
|
+
country: c.country,
|
|
232
|
+
international: c.international,
|
|
233
|
+
seasons: c.seasons.map((s) => s.name),
|
|
234
|
+
})), null, 2));
|
|
235
|
+
}
|
|
236
|
+
async function cmdCompetitions() {
|
|
237
|
+
const cache = await readStore(storeMode, dbPath);
|
|
238
|
+
console.log(JSON.stringify(cache.competitions.map((c) => ({
|
|
239
|
+
id: c.id,
|
|
240
|
+
name: c.name,
|
|
241
|
+
country: c.country,
|
|
242
|
+
international: c.international ?? false,
|
|
243
|
+
})), null, 2));
|
|
244
|
+
}
|
|
245
|
+
async function cmdSeasons(args) {
|
|
246
|
+
const name = getFlag(args, "--competition");
|
|
247
|
+
if (!name)
|
|
248
|
+
usage();
|
|
249
|
+
const cache = await readStore(storeMode, dbPath);
|
|
250
|
+
const competition = requireCompetition(cache, name);
|
|
251
|
+
const seasons = cache.seasons.filter((s) => s.competitionId === competition.id);
|
|
252
|
+
console.log(JSON.stringify(seasons.map((s) => ({ id: s.id, name: s.name, competition: competition.name })), null, 2));
|
|
253
|
+
}
|
|
254
|
+
async function cmdTeams(args) {
|
|
255
|
+
const name = getFlag(args, "--competition");
|
|
256
|
+
if (!name)
|
|
257
|
+
usage();
|
|
258
|
+
const cache = await readStore(storeMode, dbPath);
|
|
259
|
+
const competition = requireCompetition(cache, name);
|
|
260
|
+
const seasonIds = new Set(cache.seasons
|
|
261
|
+
.filter((s) => s.competitionId === competition.id)
|
|
262
|
+
.map((s) => s.id));
|
|
263
|
+
const teamIds = new Set();
|
|
264
|
+
for (const m of cache.matches) {
|
|
265
|
+
if (m.competitionId === competition.id && seasonIds.has(m.seasonId)) {
|
|
266
|
+
teamIds.add(m.homeTeamId);
|
|
267
|
+
teamIds.add(m.awayTeamId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const teams = cache.teams
|
|
271
|
+
.filter((t) => teamIds.has(t.id))
|
|
272
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
273
|
+
console.log(JSON.stringify(teams.map((t) => ({
|
|
274
|
+
id: t.id,
|
|
275
|
+
name: t.name,
|
|
276
|
+
country: t.country,
|
|
277
|
+
kind: t.kind,
|
|
278
|
+
})), null, 2));
|
|
279
|
+
}
|
|
280
|
+
async function cmdMatches(args) {
|
|
281
|
+
const competitionName = getFlag(args, "--competition");
|
|
282
|
+
if (!competitionName)
|
|
283
|
+
usage();
|
|
284
|
+
const seasonName = getFlag(args, "--season");
|
|
285
|
+
const teamName = getFlag(args, "--team");
|
|
286
|
+
const cache = await readStore(storeMode, dbPath);
|
|
287
|
+
const competition = requireCompetition(cache, competitionName);
|
|
288
|
+
let seasonId;
|
|
289
|
+
if (seasonName) {
|
|
290
|
+
const season = cache.seasons.find((s) => s.competitionId === competition.id &&
|
|
291
|
+
s.name.toLowerCase() === seasonName.toLowerCase());
|
|
292
|
+
if (!season) {
|
|
293
|
+
throw new Error(`Season not in cache: "${seasonName}"`);
|
|
294
|
+
}
|
|
295
|
+
seasonId = season.id;
|
|
296
|
+
}
|
|
297
|
+
const teamById = new Map(cache.teams.map((t) => [t.id, t]));
|
|
298
|
+
const seasonById = new Map(cache.seasons.map((s) => [s.id, s]));
|
|
299
|
+
const matches = cache.matches.filter((m) => {
|
|
300
|
+
if (m.competitionId !== competition.id)
|
|
301
|
+
return false;
|
|
302
|
+
if (seasonId && m.seasonId !== seasonId)
|
|
303
|
+
return false;
|
|
304
|
+
if (teamName) {
|
|
305
|
+
const home = teamById.get(m.homeTeamId)?.name ?? "";
|
|
306
|
+
const away = teamById.get(m.awayTeamId)?.name ?? "";
|
|
307
|
+
if (!includesCI(home, teamName) && !includesCI(away, teamName)) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
});
|
|
313
|
+
console.log(JSON.stringify(matches.map((m) => ({
|
|
314
|
+
id: m.id,
|
|
315
|
+
date: m.date,
|
|
316
|
+
season: seasonById.get(m.seasonId)?.name,
|
|
317
|
+
home: teamById.get(m.homeTeamId)?.name,
|
|
318
|
+
away: teamById.get(m.awayTeamId)?.name,
|
|
319
|
+
score: m.homeScore == null || m.awayScore == null
|
|
320
|
+
? null
|
|
321
|
+
: `${m.homeScore}-${m.awayScore}`,
|
|
322
|
+
})), null, 2));
|
|
323
|
+
}
|
|
324
|
+
async function cmdPlayers(args) {
|
|
325
|
+
const teamName = getFlag(args, "--team");
|
|
326
|
+
const name = getFlag(args, "--name");
|
|
327
|
+
const cache = await readStore(storeMode, dbPath);
|
|
328
|
+
const teamById = new Map(cache.teams.map((t) => [t.id, t]));
|
|
329
|
+
const teamIds = teamName
|
|
330
|
+
? new Set(cache.teams
|
|
331
|
+
.filter((t) => includesCI(t.name, teamName))
|
|
332
|
+
.map((t) => t.id))
|
|
333
|
+
: null;
|
|
334
|
+
let players = cache.players;
|
|
335
|
+
if (name)
|
|
336
|
+
players = players.filter((p) => includesCI(p.name, name));
|
|
337
|
+
if (teamIds) {
|
|
338
|
+
const playerIds = new Set(cache.playerMatchStats
|
|
339
|
+
.filter((s) => teamIds.has(s.teamId))
|
|
340
|
+
.map((s) => s.playerId));
|
|
341
|
+
players = players.filter((p) => playerIds.has(p.id));
|
|
342
|
+
}
|
|
343
|
+
console.log(JSON.stringify(players.map((p) => ({
|
|
344
|
+
id: p.id,
|
|
345
|
+
name: p.name,
|
|
346
|
+
nickname: p.nickname,
|
|
347
|
+
country: p.country,
|
|
348
|
+
})), null, 2));
|
|
349
|
+
}
|
|
350
|
+
async function cmdPlayerStats(args) {
|
|
351
|
+
const competitionName = getFlag(args, "--competition");
|
|
352
|
+
const matchId = getFlag(args, "--match");
|
|
353
|
+
const playerName = getFlag(args, "--player");
|
|
354
|
+
const teamName = getFlag(args, "--team");
|
|
355
|
+
const cache = await readStore(storeMode, dbPath);
|
|
356
|
+
let competitionId;
|
|
357
|
+
if (competitionName) {
|
|
358
|
+
competitionId = requireCompetition(cache, competitionName).id;
|
|
359
|
+
}
|
|
360
|
+
const matchIds = new Set(cache.matches
|
|
361
|
+
.filter((m) => (competitionId ? m.competitionId === competitionId : true))
|
|
362
|
+
.filter((m) => (matchId ? m.id === matchId || m.id.endsWith(`:${matchId}`) : true))
|
|
363
|
+
.map((m) => m.id));
|
|
364
|
+
const playerById = new Map(cache.players.map((p) => [p.id, p]));
|
|
365
|
+
const teamById = new Map(cache.teams.map((t) => [t.id, t]));
|
|
366
|
+
const rows = cache.playerMatchStats.filter((s) => {
|
|
367
|
+
if (!matchIds.has(s.matchId))
|
|
368
|
+
return false;
|
|
369
|
+
if (playerName) {
|
|
370
|
+
const n = playerById.get(s.playerId)?.name ?? "";
|
|
371
|
+
if (!includesCI(n, playerName))
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
if (teamName) {
|
|
375
|
+
const n = teamById.get(s.teamId)?.name ?? "";
|
|
376
|
+
if (!includesCI(n, teamName))
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
return true;
|
|
380
|
+
});
|
|
381
|
+
console.log(JSON.stringify(rows.map((s) => ({
|
|
382
|
+
matchId: s.matchId,
|
|
383
|
+
player: playerById.get(s.playerId)?.name,
|
|
384
|
+
team: teamById.get(s.teamId)?.name,
|
|
385
|
+
minutes: s.minutes,
|
|
386
|
+
goals: s.goals,
|
|
387
|
+
assists: s.assists,
|
|
388
|
+
yellowCards: s.yellowCards,
|
|
389
|
+
redCards: s.redCards,
|
|
390
|
+
})), null, 2));
|
|
391
|
+
}
|
|
392
|
+
async function cmdIdentities(args) {
|
|
393
|
+
const sub = args[0];
|
|
394
|
+
if (sub === "propose") {
|
|
395
|
+
const competition = getFlag(args, "--competition");
|
|
396
|
+
if (!competition)
|
|
397
|
+
usage();
|
|
398
|
+
const cache = proposeIdentitiesForCompetition(await readStore(storeMode, dbPath), competition);
|
|
399
|
+
await writeCacheToStore(storeMode, dbPath, cache);
|
|
400
|
+
console.log(JSON.stringify({
|
|
401
|
+
competition,
|
|
402
|
+
identities: cache.identities.length,
|
|
403
|
+
pending: cache.identities.filter((i) => i.status === "pending").length,
|
|
404
|
+
resolved: cache.identities.filter((i) => i.status === "resolved").length,
|
|
405
|
+
}, null, 2));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (sub === "confirm" || sub === "reject") {
|
|
409
|
+
const id = getFlag(args, "--id");
|
|
410
|
+
if (!id)
|
|
411
|
+
usage();
|
|
412
|
+
const status = sub === "confirm" ? "resolved" : "rejected";
|
|
413
|
+
const before = await readStore(storeMode, dbPath);
|
|
414
|
+
if (!before.identities.some((i) => i.id === id)) {
|
|
415
|
+
throw new Error(`Identity not found: ${id}`);
|
|
416
|
+
}
|
|
417
|
+
const cache = setIdentityStatus(before, id, status);
|
|
418
|
+
await writeCacheToStore(storeMode, dbPath, cache);
|
|
419
|
+
console.log(JSON.stringify(cache.identities.find((i) => i.id === id), null, 2));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const status = getFlag(args, "--status");
|
|
423
|
+
const cache = await readStore(storeMode, dbPath);
|
|
424
|
+
const identities = cache.identities.filter((i) => status ? i.status === status : true);
|
|
425
|
+
console.log(JSON.stringify(identities, null, 2));
|
|
426
|
+
}
|
|
427
|
+
async function cmdLineups(args) {
|
|
428
|
+
const matchId = getFlag(args, "--match");
|
|
429
|
+
const team = getFlag(args, "--team");
|
|
430
|
+
const client = CampusClient.fromCache(await readStore(storeMode, dbPath));
|
|
431
|
+
const playerById = new Map(client.data.players.map((p) => [p.id, p]));
|
|
432
|
+
const teamById = new Map(client.data.teams.map((t) => [t.id, t]));
|
|
433
|
+
console.log(JSON.stringify(client.lineups({ matchId, team }).map((l) => ({
|
|
434
|
+
matchId: l.matchId,
|
|
435
|
+
team: teamById.get(l.teamId)?.name,
|
|
436
|
+
player: playerById.get(l.playerId)?.name,
|
|
437
|
+
started: l.started,
|
|
438
|
+
jerseyNumber: l.jerseyNumber,
|
|
439
|
+
})), null, 2));
|
|
440
|
+
}
|
|
441
|
+
async function cmdSquad(args) {
|
|
442
|
+
const competition = getFlag(args, "--competition");
|
|
443
|
+
const team = getFlag(args, "--team");
|
|
444
|
+
if (!competition || !team)
|
|
445
|
+
usage();
|
|
446
|
+
const season = getFlag(args, "--season");
|
|
447
|
+
const client = CampusClient.fromCache(await readStore(storeMode, dbPath));
|
|
448
|
+
console.log(JSON.stringify(client.squad({ competition, team, season }), null, 2));
|
|
449
|
+
}
|
|
450
|
+
async function cmdFantasyPoints(args) {
|
|
451
|
+
const client = CampusClient.fromCache(await readStore(storeMode, dbPath));
|
|
452
|
+
const rows = client.fantasyPoints({
|
|
453
|
+
competition: getFlag(args, "--competition"),
|
|
454
|
+
matchId: getFlag(args, "--match"),
|
|
455
|
+
player: getFlag(args, "--player"),
|
|
456
|
+
team: getFlag(args, "--team"),
|
|
457
|
+
});
|
|
458
|
+
const playerById = new Map(client.data.players.map((p) => [p.id, p]));
|
|
459
|
+
console.log(JSON.stringify(rows.map((r) => ({
|
|
460
|
+
matchId: r.matchId,
|
|
461
|
+
player: playerById.get(r.playerId)?.name,
|
|
462
|
+
points: r.points,
|
|
463
|
+
breakdown: r.breakdown,
|
|
464
|
+
})), null, 2));
|
|
465
|
+
}
|
|
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
|
+
async function main() {
|
|
475
|
+
const [, , command, ...args] = process.argv;
|
|
476
|
+
if (!command || command === "--help" || args.includes("--help"))
|
|
477
|
+
usage();
|
|
478
|
+
const allArgs = process.argv.slice(2);
|
|
479
|
+
storeMode = resolveStoreMode(allArgs);
|
|
480
|
+
dbPath = resolveDbPath(allArgs);
|
|
481
|
+
switch (command) {
|
|
482
|
+
case "sync":
|
|
483
|
+
await cmdSync(args);
|
|
484
|
+
break;
|
|
485
|
+
case "update":
|
|
486
|
+
await cmdUpdate(args);
|
|
487
|
+
break;
|
|
488
|
+
case "pull":
|
|
489
|
+
await cmdPull(args);
|
|
490
|
+
break;
|
|
491
|
+
case "available":
|
|
492
|
+
await cmdAvailable();
|
|
493
|
+
break;
|
|
494
|
+
case "competitions":
|
|
495
|
+
await cmdCompetitions();
|
|
496
|
+
break;
|
|
497
|
+
case "seasons":
|
|
498
|
+
await cmdSeasons(args);
|
|
499
|
+
break;
|
|
500
|
+
case "teams":
|
|
501
|
+
await cmdTeams(args);
|
|
502
|
+
break;
|
|
503
|
+
case "matches":
|
|
504
|
+
await cmdMatches(args);
|
|
505
|
+
break;
|
|
506
|
+
case "players":
|
|
507
|
+
await cmdPlayers(args);
|
|
508
|
+
break;
|
|
509
|
+
case "player-stats":
|
|
510
|
+
await cmdPlayerStats(args);
|
|
511
|
+
break;
|
|
512
|
+
case "lineups":
|
|
513
|
+
await cmdLineups(args);
|
|
514
|
+
break;
|
|
515
|
+
case "squad":
|
|
516
|
+
await cmdSquad(args);
|
|
517
|
+
break;
|
|
518
|
+
case "fantasy-points":
|
|
519
|
+
await cmdFantasyPoints(args);
|
|
520
|
+
break;
|
|
521
|
+
case "injuries":
|
|
522
|
+
await cmdInjuries();
|
|
523
|
+
break;
|
|
524
|
+
case "identities":
|
|
525
|
+
await cmdIdentities(args);
|
|
526
|
+
break;
|
|
527
|
+
case "migrate":
|
|
528
|
+
await runMigrate(args);
|
|
529
|
+
break;
|
|
530
|
+
default:
|
|
531
|
+
usage();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
main().catch((err) => {
|
|
535
|
+
console.error(err instanceof Error ? err.message : err);
|
|
536
|
+
process.exit(1);
|
|
537
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-facing client: install `campus` and use this from a web API,
|
|
3
|
+
* serverless function, or Node backend. Browser bundles can import query
|
|
4
|
+
* helpers against an in-memory cache loaded via `fromBundle` / `fromCache`.
|
|
5
|
+
*/
|
|
6
|
+
import { type FantasySyncOptions } from "./fantasy.js";
|
|
7
|
+
import { type FantasyPointRow, type FantasyScoringRules } from "./scoring.js";
|
|
8
|
+
import type { CampusCache, Competition, InjuryRecord, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "./types.js";
|
|
9
|
+
export interface SquadMember {
|
|
10
|
+
playerId: string;
|
|
11
|
+
playerName: string;
|
|
12
|
+
teamId: string;
|
|
13
|
+
teamName: string;
|
|
14
|
+
appearances: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class CampusClient {
|
|
17
|
+
private cache;
|
|
18
|
+
private readonly persistPath?;
|
|
19
|
+
private constructor();
|
|
20
|
+
/** Open local JSON cache (default `.campus/cache.json`). */
|
|
21
|
+
static open(filePath?: string): Promise<CampusClient>;
|
|
22
|
+
/** Use an in-memory cache (e.g. after fetch in an API route). */
|
|
23
|
+
static fromCache(cache: CampusCache): CampusClient;
|
|
24
|
+
/** Download the cron-published bundle and optionally merge with local cache. */
|
|
25
|
+
static fromBundle(options?: {
|
|
26
|
+
url?: string;
|
|
27
|
+
mergeWith?: CampusCache;
|
|
28
|
+
persistPath?: string;
|
|
29
|
+
}): Promise<CampusClient>;
|
|
30
|
+
/** Snapshot of normalized data (safe to serialize to the browser). */
|
|
31
|
+
get data(): CampusCache;
|
|
32
|
+
save(filePath?: string): Promise<void>;
|
|
33
|
+
/** Sync every women's StatsBomb competition (clubs + national teams). */
|
|
34
|
+
syncFantasy(options?: FantasySyncOptions): Promise<CampusCache>;
|
|
35
|
+
/** Re-sync competitions already present in the cache. */
|
|
36
|
+
update(options?: FantasySyncOptions): Promise<CampusCache>;
|
|
37
|
+
/** Pull the published cron bundle into this client. */
|
|
38
|
+
pull(url?: string): Promise<CampusCache>;
|
|
39
|
+
competitions(filter?: {
|
|
40
|
+
name?: string;
|
|
41
|
+
international?: boolean;
|
|
42
|
+
}): Competition[];
|
|
43
|
+
seasons(filter?: {
|
|
44
|
+
competition?: string;
|
|
45
|
+
}): Season[];
|
|
46
|
+
teams(filter?: {
|
|
47
|
+
competition?: string;
|
|
48
|
+
kind?: Team["kind"];
|
|
49
|
+
name?: string;
|
|
50
|
+
}): Team[];
|
|
51
|
+
matches(filter?: {
|
|
52
|
+
competition?: string;
|
|
53
|
+
season?: string;
|
|
54
|
+
team?: string;
|
|
55
|
+
}): Match[];
|
|
56
|
+
players(filter?: {
|
|
57
|
+
name?: string;
|
|
58
|
+
team?: string;
|
|
59
|
+
}): Player[];
|
|
60
|
+
playerStats(filter?: {
|
|
61
|
+
competition?: string;
|
|
62
|
+
matchId?: string;
|
|
63
|
+
player?: string;
|
|
64
|
+
team?: string;
|
|
65
|
+
}): PlayerMatchStats[];
|
|
66
|
+
lineups(filter?: {
|
|
67
|
+
matchId?: string;
|
|
68
|
+
team?: string;
|
|
69
|
+
}): LineupEntry[];
|
|
70
|
+
/** Season squad = distinct players who appeared in lineups for a team. */
|
|
71
|
+
squad(filter: {
|
|
72
|
+
competition: string;
|
|
73
|
+
team: string;
|
|
74
|
+
season?: string;
|
|
75
|
+
}): SquadMember[];
|
|
76
|
+
fantasyPoints(filter?: {
|
|
77
|
+
competition?: string;
|
|
78
|
+
matchId?: string;
|
|
79
|
+
player?: string;
|
|
80
|
+
team?: string;
|
|
81
|
+
rules?: FantasyScoringRules;
|
|
82
|
+
}): FantasyPointRow[];
|
|
83
|
+
injuries(): {
|
|
84
|
+
available: boolean;
|
|
85
|
+
message: string;
|
|
86
|
+
records: InjuryRecord[];
|
|
87
|
+
};
|
|
88
|
+
}
|