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.
Files changed (58) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +163 -0
  4. package/dist/cache.d.ts +10 -0
  5. package/dist/cache.js +69 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +537 -0
  8. package/dist/client.d.ts +88 -0
  9. package/dist/client.js +273 -0
  10. package/dist/data-bundle.d.ts +11 -0
  11. package/dist/data-bundle.js +17 -0
  12. package/dist/db/migrate.d.ts +6 -0
  13. package/dist/db/migrate.js +22 -0
  14. package/dist/db/sqlite-store.d.ts +12 -0
  15. package/dist/db/sqlite-store.js +344 -0
  16. package/dist/db/store-api.d.ts +9 -0
  17. package/dist/db/store-api.js +70 -0
  18. package/dist/fantasy.d.ts +36 -0
  19. package/dist/fantasy.js +152 -0
  20. package/dist/http.d.ts +25 -0
  21. package/dist/http.js +63 -0
  22. package/dist/identity/match.d.ts +22 -0
  23. package/dist/identity/match.js +106 -0
  24. package/dist/identity/propose.d.ts +8 -0
  25. package/dist/identity/propose.js +45 -0
  26. package/dist/identity/rules.d.ts +8 -0
  27. package/dist/identity/rules.js +17 -0
  28. package/dist/identity/store.d.ts +5 -0
  29. package/dist/identity/store.js +20 -0
  30. package/dist/identity/types.d.ts +32 -0
  31. package/dist/identity/types.js +8 -0
  32. package/dist/ids.d.ts +2 -0
  33. package/dist/ids.js +4 -0
  34. package/dist/index.d.ts +16 -0
  35. package/dist/index.js +9 -0
  36. package/dist/injuries.d.ts +12 -0
  37. package/dist/injuries.js +15 -0
  38. package/dist/refs.d.ts +7 -0
  39. package/dist/refs.js +1 -0
  40. package/dist/scoring.d.ts +37 -0
  41. package/dist/scoring.js +56 -0
  42. package/dist/sources/fbref-map.d.ts +14 -0
  43. package/dist/sources/fbref-map.js +56 -0
  44. package/dist/sources/fbref-parse.d.ts +17 -0
  45. package/dist/sources/fbref-parse.js +87 -0
  46. package/dist/sources/fbref.d.ts +27 -0
  47. package/dist/sources/fbref.js +72 -0
  48. package/dist/sources/index.d.ts +3 -0
  49. package/dist/sources/index.js +2 -0
  50. package/dist/sources/statsbomb-player-stats.d.ts +63 -0
  51. package/dist/sources/statsbomb-player-stats.js +107 -0
  52. package/dist/sources/statsbomb.d.ts +50 -0
  53. package/dist/sources/statsbomb.js +170 -0
  54. package/dist/sources/types.d.ts +23 -0
  55. package/dist/sources/types.js +1 -0
  56. package/dist/types.d.ts +114 -0
  57. package/dist/types.js +1 -0
  58. package/package.json +57 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * StatsBomb Open Data adapter.
3
+ *
4
+ * Upstream: https://github.com/statsbomb/open-data
5
+ * Free for research and genuine football-analytics use. If you publish
6
+ * analysis built on this data, credit StatsBomb
7
+ * (https://statsbomb.com/media-pack/). Pass that requirement downstream.
8
+ *
9
+ * This adapter only surfaces women's competitions
10
+ * (`competition_gender === "female"`).
11
+ */
12
+ import { aggregateStatsBombPlayerMatch } from "./statsbomb-player-stats.js";
13
+ import { entityId } from "../ids.js";
14
+ const SOURCE = "statsbomb";
15
+ const BASE = "https://raw.githubusercontent.com/statsbomb/open-data/master/data";
16
+ function normalizeName(name) {
17
+ return name.trim().toLowerCase();
18
+ }
19
+ async function defaultFetchJson(url) {
20
+ const res = await fetch(url);
21
+ if (!res.ok) {
22
+ throw new Error(`StatsBomb fetch failed (${res.status}): ${url}`);
23
+ }
24
+ return (await res.json());
25
+ }
26
+ export class StatsBombSource {
27
+ id = SOURCE;
28
+ baseUrl;
29
+ fetchJson;
30
+ includePlayerStats;
31
+ playerStatsLimit;
32
+ constructor(options = {}) {
33
+ this.baseUrl = options.baseUrl ?? BASE;
34
+ this.fetchJson = options.fetchJson ?? defaultFetchJson;
35
+ this.includePlayerStats = options.includePlayerStats ?? false;
36
+ this.playerStatsLimit = options.playerStatsLimit ?? 5;
37
+ }
38
+ /** Catalogue of women's competitions (clubs + national-team tournaments). */
39
+ async listWomenCompetitions() {
40
+ const all = await this.fetchJson(`${this.baseUrl}/competitions.json`);
41
+ const byId = new Map();
42
+ for (const row of all) {
43
+ if (row.competition_gender !== "female")
44
+ continue;
45
+ const existing = byId.get(row.competition_id);
46
+ if (!existing) {
47
+ byId.set(row.competition_id, {
48
+ id: row.competition_id,
49
+ name: row.competition_name,
50
+ country: row.country_name,
51
+ international: Boolean(row.competition_international),
52
+ seasons: [{ id: row.season_id, name: row.season_name }],
53
+ });
54
+ }
55
+ else {
56
+ existing.seasons.push({ id: row.season_id, name: row.season_name });
57
+ }
58
+ }
59
+ return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
60
+ }
61
+ async syncCompetition(competitionName) {
62
+ const all = await this.fetchJson(`${this.baseUrl}/competitions.json`);
63
+ const rows = all.filter((c) => c.competition_gender === "female" &&
64
+ normalizeName(c.competition_name) === normalizeName(competitionName));
65
+ if (rows.length === 0) {
66
+ const available = [
67
+ ...new Set(all
68
+ .filter((c) => c.competition_gender === "female")
69
+ .map((c) => c.competition_name)),
70
+ ].sort();
71
+ throw new Error(`Competition not found in StatsBomb women's data: "${competitionName}". ` +
72
+ `Available: ${available.join(", ")}`);
73
+ }
74
+ const competitions = new Map();
75
+ const seasons = new Map();
76
+ const teams = new Map();
77
+ const matches = [];
78
+ for (const row of rows) {
79
+ const competitionId = entityId(SOURCE, "comp", row.competition_id);
80
+ const international = Boolean(row.competition_international);
81
+ const teamKind = international ? "national" : "club";
82
+ competitions.set(competitionId, {
83
+ id: competitionId,
84
+ name: row.competition_name,
85
+ country: row.country_name,
86
+ gender: "female",
87
+ international,
88
+ sources: [{ source: SOURCE, id: String(row.competition_id) }],
89
+ });
90
+ const seasonId = entityId(SOURCE, "season", row.competition_id, row.season_id);
91
+ seasons.set(seasonId, {
92
+ id: seasonId,
93
+ name: row.season_name,
94
+ competitionId,
95
+ sources: [
96
+ {
97
+ source: SOURCE,
98
+ id: `${row.competition_id}:${row.season_id}`,
99
+ },
100
+ ],
101
+ });
102
+ const matchRows = await this.fetchJson(`${this.baseUrl}/matches/${row.competition_id}/${row.season_id}.json`);
103
+ for (const m of matchRows) {
104
+ const homeId = entityId(SOURCE, "team", m.home_team.home_team_id);
105
+ const awayId = entityId(SOURCE, "team", m.away_team.away_team_id);
106
+ teams.set(homeId, {
107
+ id: homeId,
108
+ name: m.home_team.home_team_name,
109
+ country: m.home_team.country?.name,
110
+ kind: teamKind,
111
+ sources: [{ source: SOURCE, id: String(m.home_team.home_team_id) }],
112
+ });
113
+ teams.set(awayId, {
114
+ id: awayId,
115
+ name: m.away_team.away_team_name,
116
+ country: m.away_team.country?.name,
117
+ kind: teamKind,
118
+ sources: [{ source: SOURCE, id: String(m.away_team.away_team_id) }],
119
+ });
120
+ matches.push({
121
+ id: entityId(SOURCE, "match", m.match_id),
122
+ competitionId,
123
+ seasonId,
124
+ date: m.match_date,
125
+ homeTeamId: homeId,
126
+ awayTeamId: awayId,
127
+ homeScore: m.home_score,
128
+ awayScore: m.away_score,
129
+ sources: [{ source: SOURCE, id: String(m.match_id) }],
130
+ });
131
+ }
132
+ }
133
+ const result = {
134
+ competitions: [...competitions.values()],
135
+ seasons: [...seasons.values()],
136
+ teams: [...teams.values()],
137
+ matches,
138
+ };
139
+ if (this.includePlayerStats) {
140
+ const enriched = await this.loadPlayerStatsForMatches(matches.slice(0, this.playerStatsLimit));
141
+ result.players = enriched.players;
142
+ result.playerMatchStats = enriched.playerMatchStats;
143
+ result.lineups = enriched.lineups;
144
+ }
145
+ return result;
146
+ }
147
+ /** Load aggregated v1 player stats + lineups for the given canonical matches. */
148
+ async loadPlayerStatsForMatches(matches) {
149
+ const players = new Map();
150
+ const playerMatchStats = [];
151
+ const lineupEntries = [];
152
+ for (const match of matches) {
153
+ const nativeId = match.sources.find((s) => s.source === SOURCE)?.id;
154
+ if (!nativeId)
155
+ continue;
156
+ const lineups = await this.fetchJson(`${this.baseUrl}/lineups/${nativeId}.json`);
157
+ const events = await this.fetchJson(`${this.baseUrl}/events/${nativeId}.json`);
158
+ const agg = aggregateStatsBombPlayerMatch(match.id, lineups, events);
159
+ for (const p of agg.players)
160
+ players.set(p.id, p);
161
+ playerMatchStats.push(...agg.playerMatchStats);
162
+ lineupEntries.push(...agg.lineups);
163
+ }
164
+ return {
165
+ players: [...players.values()],
166
+ playerMatchStats,
167
+ lineups: lineupEntries,
168
+ };
169
+ }
170
+ }
@@ -0,0 +1,23 @@
1
+ import type { Competition, InjuryRecord, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "../types.js";
2
+ /** Result of syncing one competition from a provider into canonical entities. */
3
+ export interface SyncResult {
4
+ competitions: Competition[];
5
+ seasons: Season[];
6
+ teams: Team[];
7
+ matches: Match[];
8
+ players?: Player[];
9
+ playerMatchStats?: PlayerMatchStats[];
10
+ lineups?: LineupEntry[];
11
+ injuries?: InjuryRecord[];
12
+ }
13
+ /**
14
+ * Provider adapter contract.
15
+ * Implementations map native payloads onto the campus schema and
16
+ * attach provenance via `sources` on every entity.
17
+ */
18
+ export interface FootballSource {
19
+ /** Stable provider key (also used in SourceRef.source). */
20
+ readonly id: string;
21
+ /** Sync all seasons/teams/matches for a competition display name. */
22
+ syncCompetition(competitionName: string): Promise<SyncResult>;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
1
+ import type { SourceRef } from "./refs.js";
2
+ import type { CanonicalIdentity } from "./identity/types.js";
3
+ export type { SourceRef } from "./refs.js";
4
+ /**
5
+ * Canonical domain schema for campus.
6
+ * Provider-agnostic — adapters map raw source fields into these types.
7
+ */
8
+ /** Club side vs national team (selección). */
9
+ export type TeamKind = "club" | "national";
10
+ export interface Competition {
11
+ /** Stable campus id (derived from primary source + native id). */
12
+ id: string;
13
+ /** Display name, e.g. "Liga F". */
14
+ name: string;
15
+ /** ISO-ish country or region label when known. */
16
+ country?: string;
17
+ /** Always women's football in this package; kept for clarity. */
18
+ gender: "female";
19
+ /** True for World Cup, Euro, etc. (national-team tournaments). */
20
+ international?: boolean;
21
+ sources: SourceRef[];
22
+ }
23
+ export interface Season {
24
+ id: string;
25
+ /** Display label, e.g. "2023/2024". */
26
+ name: string;
27
+ competitionId: string;
28
+ sources: SourceRef[];
29
+ }
30
+ export interface Team {
31
+ id: string;
32
+ name: string;
33
+ country?: string;
34
+ /** Club or national team when known from the competition context. */
35
+ kind?: TeamKind;
36
+ sources: SourceRef[];
37
+ }
38
+ export interface Match {
39
+ id: string;
40
+ competitionId: string;
41
+ seasonId: string;
42
+ /** Kick-off date YYYY-MM-DD when known. */
43
+ date?: string;
44
+ homeTeamId: string;
45
+ awayTeamId: string;
46
+ homeScore?: number | null;
47
+ awayScore?: number | null;
48
+ sources: SourceRef[];
49
+ }
50
+ /** Player identity at package level (not per-match). */
51
+ export interface Player {
52
+ id: string;
53
+ name: string;
54
+ /** Preferred short name when known. */
55
+ nickname?: string;
56
+ country?: string;
57
+ sources: SourceRef[];
58
+ }
59
+ /**
60
+ * Aggregated per-player stats for a single match (v1 metrics subset).
61
+ * See docs/player-stats.md for the metric contract.
62
+ */
63
+ export interface PlayerMatchStats {
64
+ id: string;
65
+ matchId: string;
66
+ playerId: string;
67
+ teamId: string;
68
+ /** Minutes played when known. */
69
+ minutes?: number | null;
70
+ goals: number;
71
+ assists: number;
72
+ yellowCards: number;
73
+ redCards: number;
74
+ sources: SourceRef[];
75
+ }
76
+ /** One player appearance in a match squad / lineup. */
77
+ export interface LineupEntry {
78
+ id: string;
79
+ matchId: string;
80
+ teamId: string;
81
+ playerId: string;
82
+ /** True when the player has a recorded on-pitch position interval. */
83
+ started: boolean;
84
+ jerseyNumber?: number | null;
85
+ sources: SourceRef[];
86
+ }
87
+ /**
88
+ * Injury / availability record.
89
+ * Open StatsBomb data does not include injuries — this shape is reserved for
90
+ * future adapters; the CLI currently returns an empty list.
91
+ */
92
+ export interface InjuryRecord {
93
+ id: string;
94
+ playerId: string;
95
+ teamId?: string;
96
+ status: "injured" | "doubtful" | "suspended" | "unknown";
97
+ description?: string;
98
+ fromDate?: string;
99
+ toDate?: string;
100
+ sources: SourceRef[];
101
+ }
102
+ /** In-memory / on-disk cache shape (JSON file in v0). */
103
+ export interface CampusCache {
104
+ competitions: Competition[];
105
+ seasons: Season[];
106
+ teams: Team[];
107
+ matches: Match[];
108
+ /** Cross-source identity resolutions (epic 02). */
109
+ identities: CanonicalIdentity[];
110
+ players: Player[];
111
+ playerMatchStats: PlayerMatchStats[];
112
+ lineups: LineupEntry[];
113
+ injuries: InjuryRecord[];
114
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "campus-stats",
3
+ "version": "0.4.0",
4
+ "description": "Campus — installable women's football data layer for apps (clubs, national teams, matches, fantasy points)",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "campus": "./dist/cli.js",
16
+ "campus-stats": "./dist/cli.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "LICENSE",
21
+ "README.md",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "prepack": "npm run build",
27
+ "test": "node --import tsx --test test/**/*.test.ts"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "keywords": [
33
+ "campus",
34
+ "campus-stats",
35
+ "football",
36
+ "soccer",
37
+ "women",
38
+ "fantasy",
39
+ "stats",
40
+ "statsbomb",
41
+ "cli"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://gitlab.com/marina34/campus.git"
46
+ },
47
+ "homepage": "https://gitlab.com/marina34/campus",
48
+ "bugs": {
49
+ "url": "https://gitlab.com/marina34/campus/-/issues"
50
+ },
51
+ "license": "MIT",
52
+ "devDependencies": {
53
+ "@types/node": "^22.13.10",
54
+ "tsx": "^4.19.3",
55
+ "typescript": "^5.8.2"
56
+ }
57
+ }