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/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { StatsBombSource } from "./sources/statsbomb.js";
|
|
2
|
+
export { FbrefSource, listFbrefPilotNames } from "./sources/fbref.js";
|
|
3
|
+
export { emptyCache, cachePath, loadCache, saveCache, mergeSyncResult, DEFAULT_CACHE_DIR, DEFAULT_CACHE_FILE, } from "./cache.js";
|
|
4
|
+
export { SqliteStore, SCHEMA_SQL } from "./db/sqlite-store.js";
|
|
5
|
+
export { FANTASY_PLAYER_STATS_LIMIT, listWomenCompetitions, syncFantasyBundle, updateCachedCompetitions, competitionIsInternational, } from "./fantasy.js";
|
|
6
|
+
export { GITLAB_PROJECT_ID, DATA_BUNDLE_PACKAGE, DATA_BUNDLE_VERSION, DATA_BUNDLE_FILE, dataBundleUrl, dataBundleMetaUrl, } from "./data-bundle.js";
|
|
7
|
+
export { CampusClient } from "./client.js";
|
|
8
|
+
export { DEFAULT_FANTASY_RULES, scorePlayerMatchStats, scoreFantasyPoints, } from "./scoring.js";
|
|
9
|
+
export { INJURIES_AVAILABLE, INJURIES_STATUS_MESSAGE, listInjuries, } from "./injuries.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injury / availability feed.
|
|
3
|
+
*
|
|
4
|
+
* StatsBomb Open Data (and our FBref pilot) do not publish injury lists.
|
|
5
|
+
* This module keeps a stable API so fantasy apps can call `listInjuries`
|
|
6
|
+
* today and plug a future adapter without changing call sites.
|
|
7
|
+
*/
|
|
8
|
+
import type { CampusCache, InjuryRecord } from "./types.js";
|
|
9
|
+
export declare const INJURIES_AVAILABLE = false;
|
|
10
|
+
export declare const INJURIES_STATUS_MESSAGE: string;
|
|
11
|
+
/** Always empty until an injury-capable adapter is added. */
|
|
12
|
+
export declare function listInjuries(cache: CampusCache): InjuryRecord[];
|
package/dist/injuries.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injury / availability feed.
|
|
3
|
+
*
|
|
4
|
+
* StatsBomb Open Data (and our FBref pilot) do not publish injury lists.
|
|
5
|
+
* This module keeps a stable API so fantasy apps can call `listInjuries`
|
|
6
|
+
* today and plug a future adapter without changing call sites.
|
|
7
|
+
*/
|
|
8
|
+
export const INJURIES_AVAILABLE = false;
|
|
9
|
+
export const INJURIES_STATUS_MESSAGE = "Injury feeds are not available from current open sources (StatsBomb / FBref pilot). " +
|
|
10
|
+
"campus exposes InjuryRecord + listInjuries() for future adapters; " +
|
|
11
|
+
"today the list is always empty.";
|
|
12
|
+
/** Always empty until an injury-capable adapter is added. */
|
|
13
|
+
export function listInjuries(cache) {
|
|
14
|
+
return cache.injuries ?? [];
|
|
15
|
+
}
|
package/dist/refs.d.ts
ADDED
package/dist/refs.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CampusCache, PlayerMatchStats } from "./types.js";
|
|
2
|
+
/** Default fantasy scoring weights (outfield-oriented v1). */
|
|
3
|
+
export interface FantasyScoringRules {
|
|
4
|
+
appearance: number;
|
|
5
|
+
goal: number;
|
|
6
|
+
assist: number;
|
|
7
|
+
yellowCard: number;
|
|
8
|
+
redCard: number;
|
|
9
|
+
/** Bonus when minutes >= this threshold (0 disables). */
|
|
10
|
+
fullMatchMinutes: number;
|
|
11
|
+
fullMatchBonus: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_FANTASY_RULES: FantasyScoringRules;
|
|
14
|
+
export interface FantasyPointRow {
|
|
15
|
+
matchId: string;
|
|
16
|
+
playerId: string;
|
|
17
|
+
teamId: string;
|
|
18
|
+
points: number;
|
|
19
|
+
breakdown: {
|
|
20
|
+
appearance: number;
|
|
21
|
+
goals: number;
|
|
22
|
+
assists: number;
|
|
23
|
+
yellowCards: number;
|
|
24
|
+
redCards: number;
|
|
25
|
+
fullMatchBonus: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Score one PlayerMatchStats row with the given rules. */
|
|
29
|
+
export declare function scorePlayerMatchStats(stats: PlayerMatchStats, rules?: FantasyScoringRules): FantasyPointRow;
|
|
30
|
+
/** Score every player-match row in a cache (optionally filtered). */
|
|
31
|
+
export declare function scoreFantasyPoints(cache: CampusCache, options?: {
|
|
32
|
+
competitionId?: string;
|
|
33
|
+
matchId?: string;
|
|
34
|
+
playerId?: string;
|
|
35
|
+
teamId?: string;
|
|
36
|
+
rules?: FantasyScoringRules;
|
|
37
|
+
}): FantasyPointRow[];
|
package/dist/scoring.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const DEFAULT_FANTASY_RULES = {
|
|
2
|
+
appearance: 1,
|
|
3
|
+
goal: 4,
|
|
4
|
+
assist: 3,
|
|
5
|
+
yellowCard: -1,
|
|
6
|
+
redCard: -3,
|
|
7
|
+
fullMatchMinutes: 60,
|
|
8
|
+
fullMatchBonus: 1,
|
|
9
|
+
};
|
|
10
|
+
/** Score one PlayerMatchStats row with the given rules. */
|
|
11
|
+
export function scorePlayerMatchStats(stats, rules = DEFAULT_FANTASY_RULES) {
|
|
12
|
+
const minutes = stats.minutes ?? 0;
|
|
13
|
+
const appearance = minutes > 0 || stats.goals > 0 || stats.assists > 0
|
|
14
|
+
? rules.appearance
|
|
15
|
+
: 0;
|
|
16
|
+
const goals = stats.goals * rules.goal;
|
|
17
|
+
const assists = stats.assists * rules.assist;
|
|
18
|
+
const yellowCards = stats.yellowCards * rules.yellowCard;
|
|
19
|
+
const redCards = stats.redCards * rules.redCard;
|
|
20
|
+
const fullMatchBonus = rules.fullMatchMinutes > 0 && minutes >= rules.fullMatchMinutes
|
|
21
|
+
? rules.fullMatchBonus
|
|
22
|
+
: 0;
|
|
23
|
+
return {
|
|
24
|
+
matchId: stats.matchId,
|
|
25
|
+
playerId: stats.playerId,
|
|
26
|
+
teamId: stats.teamId,
|
|
27
|
+
points: appearance + goals + assists + yellowCards + redCards + fullMatchBonus,
|
|
28
|
+
breakdown: {
|
|
29
|
+
appearance,
|
|
30
|
+
goals,
|
|
31
|
+
assists,
|
|
32
|
+
yellowCards,
|
|
33
|
+
redCards,
|
|
34
|
+
fullMatchBonus,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Score every player-match row in a cache (optionally filtered). */
|
|
39
|
+
export function scoreFantasyPoints(cache, options = {}) {
|
|
40
|
+
const matchIds = new Set(cache.matches
|
|
41
|
+
.filter((m) => options.competitionId ? m.competitionId === options.competitionId : true)
|
|
42
|
+
.map((m) => m.id));
|
|
43
|
+
return cache.playerMatchStats
|
|
44
|
+
.filter((s) => {
|
|
45
|
+
if (!matchIds.has(s.matchId))
|
|
46
|
+
return false;
|
|
47
|
+
if (options.matchId && s.matchId !== options.matchId)
|
|
48
|
+
return false;
|
|
49
|
+
if (options.playerId && s.playerId !== options.playerId)
|
|
50
|
+
return false;
|
|
51
|
+
if (options.teamId && s.teamId !== options.teamId)
|
|
52
|
+
return false;
|
|
53
|
+
return true;
|
|
54
|
+
})
|
|
55
|
+
.map((s) => scorePlayerMatchStats(s, options.rules));
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SyncResult } from "./types.js";
|
|
2
|
+
import type { FbrefScheduleRow } from "./fbref-parse.js";
|
|
3
|
+
export interface FbrefCompetitionMeta {
|
|
4
|
+
/** FBref competition id, e.g. "189". */
|
|
5
|
+
fbrefCompetitionId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
country?: string;
|
|
8
|
+
/** Season display label, e.g. "2023/2024". */
|
|
9
|
+
seasonName: string;
|
|
10
|
+
/** Optional season path segment, e.g. "2023-2024". */
|
|
11
|
+
seasonSlug?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Map parsed FBref schedule rows into campus canonical entities. */
|
|
14
|
+
export declare function mapFbrefScheduleToSyncResult(meta: FbrefCompetitionMeta, rows: FbrefScheduleRow[]): SyncResult;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { entityId } from "../ids.js";
|
|
2
|
+
const SOURCE = "fbref";
|
|
3
|
+
/** Map parsed FBref schedule rows into campus canonical entities. */
|
|
4
|
+
export function mapFbrefScheduleToSyncResult(meta, rows) {
|
|
5
|
+
const competitionId = entityId(SOURCE, "comp", meta.fbrefCompetitionId);
|
|
6
|
+
const seasonKey = meta.seasonSlug ?? meta.seasonName.replace(/\//g, "-");
|
|
7
|
+
const seasonId = entityId(SOURCE, "season", meta.fbrefCompetitionId, seasonKey);
|
|
8
|
+
const competition = {
|
|
9
|
+
id: competitionId,
|
|
10
|
+
name: meta.name,
|
|
11
|
+
country: meta.country,
|
|
12
|
+
gender: "female",
|
|
13
|
+
sources: [{ source: SOURCE, id: meta.fbrefCompetitionId }],
|
|
14
|
+
};
|
|
15
|
+
const season = {
|
|
16
|
+
id: seasonId,
|
|
17
|
+
name: meta.seasonName,
|
|
18
|
+
competitionId,
|
|
19
|
+
sources: [{ source: SOURCE, id: `${meta.fbrefCompetitionId}:${seasonKey}` }],
|
|
20
|
+
};
|
|
21
|
+
const teams = new Map();
|
|
22
|
+
const matches = [];
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
const homeId = entityId(SOURCE, "team", row.homeTeamId);
|
|
25
|
+
const awayId = entityId(SOURCE, "team", row.awayTeamId);
|
|
26
|
+
teams.set(homeId, {
|
|
27
|
+
id: homeId,
|
|
28
|
+
name: row.homeTeamName,
|
|
29
|
+
sources: [{ source: SOURCE, id: row.homeTeamId }],
|
|
30
|
+
});
|
|
31
|
+
teams.set(awayId, {
|
|
32
|
+
id: awayId,
|
|
33
|
+
name: row.awayTeamName,
|
|
34
|
+
sources: [{ source: SOURCE, id: row.awayTeamId }],
|
|
35
|
+
});
|
|
36
|
+
const matchNativeId = row.matchId ??
|
|
37
|
+
`${meta.fbrefCompetitionId}:${row.date ?? "undated"}:${row.homeTeamId}:${row.awayTeamId}`;
|
|
38
|
+
matches.push({
|
|
39
|
+
id: entityId(SOURCE, "match", matchNativeId),
|
|
40
|
+
competitionId,
|
|
41
|
+
seasonId,
|
|
42
|
+
date: row.date,
|
|
43
|
+
homeTeamId: homeId,
|
|
44
|
+
awayTeamId: awayId,
|
|
45
|
+
homeScore: row.homeScore,
|
|
46
|
+
awayScore: row.awayScore,
|
|
47
|
+
sources: [{ source: SOURCE, id: matchNativeId }],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
competitions: [competition],
|
|
52
|
+
seasons: [season],
|
|
53
|
+
teams: [...teams.values()],
|
|
54
|
+
matches,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTML schedule parser for FBref `sched_*` tables.
|
|
3
|
+
* Only reads data-stat attributes documented in docs/fbref-pilot.md.
|
|
4
|
+
*/
|
|
5
|
+
export interface FbrefScheduleRow {
|
|
6
|
+
date?: string;
|
|
7
|
+
homeTeamId: string;
|
|
8
|
+
homeTeamName: string;
|
|
9
|
+
awayTeamId: string;
|
|
10
|
+
awayTeamName: string;
|
|
11
|
+
homeScore?: number | null;
|
|
12
|
+
awayScore?: number | null;
|
|
13
|
+
/** FBref match id when present in the score link. */
|
|
14
|
+
matchId?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Extract match rows from a FBref competition schedule HTML page. */
|
|
17
|
+
export declare function parseFbrefSchedule(html: string): FbrefScheduleRow[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTML schedule parser for FBref `sched_*` tables.
|
|
3
|
+
* Only reads data-stat attributes documented in docs/fbref-pilot.md.
|
|
4
|
+
*/
|
|
5
|
+
function decodeBasicEntities(text) {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/ /g, " ")
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
12
|
+
.replace(/\s+/g, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
function cellHtml(rowHtml, stat) {
|
|
16
|
+
const re = new RegExp(`<t[hd]\\b[^>]*\\bdata-stat="${stat}"[^>]*>([\\s\\S]*?)</t[hd]>`, "i");
|
|
17
|
+
const m = rowHtml.match(re);
|
|
18
|
+
return m?.[1];
|
|
19
|
+
}
|
|
20
|
+
function firstLink(cell) {
|
|
21
|
+
if (!cell)
|
|
22
|
+
return undefined;
|
|
23
|
+
const m = cell.match(/<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
24
|
+
if (!m) {
|
|
25
|
+
const text = decodeBasicEntities(cell.replace(/<[^>]+>/g, ""));
|
|
26
|
+
return text ? { href: "", text } : undefined;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
href: m[1] ?? "",
|
|
30
|
+
text: decodeBasicEntities((m[2] ?? "").replace(/<[^>]+>/g, "")),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function squadFromHref(href) {
|
|
34
|
+
const m = href.match(/\/squads\/([a-f0-9]+)\//i);
|
|
35
|
+
return m?.[1];
|
|
36
|
+
}
|
|
37
|
+
function matchFromHref(href) {
|
|
38
|
+
const m = href.match(/\/matches\/([a-z0-9]+)\//i);
|
|
39
|
+
return m?.[1];
|
|
40
|
+
}
|
|
41
|
+
function parseScore(raw) {
|
|
42
|
+
const text = decodeBasicEntities(raw.replace(/<[^>]+>/g, ""));
|
|
43
|
+
if (!text)
|
|
44
|
+
return { home: null, away: null };
|
|
45
|
+
const m = text.match(/(\d+)\s*[–\-—]\s*(\d+)/);
|
|
46
|
+
if (!m)
|
|
47
|
+
return {};
|
|
48
|
+
return { home: Number(m[1]), away: Number(m[2]) };
|
|
49
|
+
}
|
|
50
|
+
/** Extract match rows from a FBref competition schedule HTML page. */
|
|
51
|
+
export function parseFbrefSchedule(html) {
|
|
52
|
+
const tableMatch = html.match(/<table\b[^>]*\bid="sched_[^"]+"[^>]*>([\s\S]*?)<\/table>/i);
|
|
53
|
+
const tableHtml = tableMatch?.[1] ?? html;
|
|
54
|
+
const bodyMatch = tableHtml.match(/<tbody\b[^>]*>([\s\S]*?)<\/tbody>/i);
|
|
55
|
+
const body = bodyMatch?.[1] ?? tableHtml;
|
|
56
|
+
const rows = [];
|
|
57
|
+
const rowRe = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
58
|
+
let rowMatch;
|
|
59
|
+
while ((rowMatch = rowRe.exec(body))) {
|
|
60
|
+
const rowHtml = rowMatch[1] ?? "";
|
|
61
|
+
if (/spacer|thead/i.test(rowMatch[0]))
|
|
62
|
+
continue;
|
|
63
|
+
const homeLink = firstLink(cellHtml(rowHtml, "home_team"));
|
|
64
|
+
const awayLink = firstLink(cellHtml(rowHtml, "away_team"));
|
|
65
|
+
if (!homeLink || !awayLink)
|
|
66
|
+
continue;
|
|
67
|
+
const homeTeamId = squadFromHref(homeLink.href);
|
|
68
|
+
const awayTeamId = squadFromHref(awayLink.href);
|
|
69
|
+
if (!homeTeamId || !awayTeamId)
|
|
70
|
+
continue;
|
|
71
|
+
const dateLink = firstLink(cellHtml(rowHtml, "date"));
|
|
72
|
+
const scoreCell = cellHtml(rowHtml, "score") ?? "";
|
|
73
|
+
const scoreLink = firstLink(scoreCell);
|
|
74
|
+
const score = parseScore(scoreCell);
|
|
75
|
+
rows.push({
|
|
76
|
+
date: dateLink?.text || undefined,
|
|
77
|
+
homeTeamId,
|
|
78
|
+
homeTeamName: homeLink.text,
|
|
79
|
+
awayTeamId,
|
|
80
|
+
awayTeamName: awayLink.text,
|
|
81
|
+
homeScore: score.home,
|
|
82
|
+
awayScore: score.away,
|
|
83
|
+
matchId: scoreLink ? matchFromHref(scoreLink.href) : undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FBref HTML adapter (women's competitions).
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://www.fbref.com/
|
|
5
|
+
* Respect Sports Reference terms, robots.txt, and rate limits. Prefer the
|
|
6
|
+
* polite HttpClient defaults. This environment often receives Cloudflare
|
|
7
|
+
* 403s; use fixtures / --fixture for offline sync.
|
|
8
|
+
*
|
|
9
|
+
* See docs/fbref-pilot.md for competition ids and HTML shape.
|
|
10
|
+
*/
|
|
11
|
+
import { HttpClient } from "../http.js";
|
|
12
|
+
import type { FootballSource, SyncResult } from "./types.js";
|
|
13
|
+
export interface FbrefSourceOptions {
|
|
14
|
+
http?: HttpClient;
|
|
15
|
+
/** Override HTML loader (fixtures / tests). */
|
|
16
|
+
loadHtml?: (url: string) => Promise<string>;
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare class FbrefSource implements FootballSource {
|
|
20
|
+
readonly id = "fbref";
|
|
21
|
+
private readonly http;
|
|
22
|
+
private readonly loadHtml;
|
|
23
|
+
private readonly baseUrl;
|
|
24
|
+
constructor(options?: FbrefSourceOptions);
|
|
25
|
+
syncCompetition(competitionName: string): Promise<SyncResult>;
|
|
26
|
+
}
|
|
27
|
+
export declare function listFbrefPilotNames(): string[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FBref HTML adapter (women's competitions).
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://www.fbref.com/
|
|
5
|
+
* Respect Sports Reference terms, robots.txt, and rate limits. Prefer the
|
|
6
|
+
* polite HttpClient defaults. This environment often receives Cloudflare
|
|
7
|
+
* 403s; use fixtures / --fixture for offline sync.
|
|
8
|
+
*
|
|
9
|
+
* See docs/fbref-pilot.md for competition ids and HTML shape.
|
|
10
|
+
*/
|
|
11
|
+
import { HttpClient } from "../http.js";
|
|
12
|
+
import { parseFbrefSchedule } from "./fbref-parse.js";
|
|
13
|
+
import { mapFbrefScheduleToSyncResult, } from "./fbref-map.js";
|
|
14
|
+
const SOURCE = "fbref";
|
|
15
|
+
const BASE = "https://fbref.com";
|
|
16
|
+
const PILOTS = [
|
|
17
|
+
{
|
|
18
|
+
fbrefCompetitionId: "189",
|
|
19
|
+
name: "FA Women's Super League",
|
|
20
|
+
country: "England",
|
|
21
|
+
seasonName: "2023/2024",
|
|
22
|
+
seasonSlug: "2023-2024",
|
|
23
|
+
aliases: ["wsl", "fa women's super league", "women's super league"],
|
|
24
|
+
schedulePath: "/en/comps/189/2023-2024/schedule/2023-2024-Womens-Super-League-Scores-and-Fixtures",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
fbrefCompetitionId: "230",
|
|
28
|
+
name: "Liga F",
|
|
29
|
+
country: "Spain",
|
|
30
|
+
seasonName: "2023/2024",
|
|
31
|
+
seasonSlug: "2023-2024",
|
|
32
|
+
aliases: ["liga f"],
|
|
33
|
+
schedulePath: "/en/comps/230/2023-2024/schedule/2023-2024-Liga-F-Scores-and-Fixtures",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
function normalize(name) {
|
|
37
|
+
return name.trim().toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
export class FbrefSource {
|
|
40
|
+
id = SOURCE;
|
|
41
|
+
http;
|
|
42
|
+
loadHtml;
|
|
43
|
+
baseUrl;
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.http = options.http ?? new HttpClient();
|
|
46
|
+
this.baseUrl = options.baseUrl ?? BASE;
|
|
47
|
+
this.loadHtml =
|
|
48
|
+
options.loadHtml ?? ((url) => this.http.getText(url));
|
|
49
|
+
}
|
|
50
|
+
async syncCompetition(competitionName) {
|
|
51
|
+
const pilot = PILOTS.find((p) => normalize(p.name) === normalize(competitionName) ||
|
|
52
|
+
p.aliases.includes(normalize(competitionName)));
|
|
53
|
+
if (!pilot) {
|
|
54
|
+
throw new Error(`Competition not supported by FBref adapter yet: "${competitionName}". ` +
|
|
55
|
+
`Pilots: ${PILOTS.map((p) => p.name).join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
const url = `${this.baseUrl}${pilot.schedulePath}`;
|
|
58
|
+
const html = await this.loadHtml(url);
|
|
59
|
+
if (/Just a moment/i.test(html) || /cf-browser-verification/i.test(html)) {
|
|
60
|
+
throw new Error(`FBref returned a Cloudflare challenge for ${url}. ` +
|
|
61
|
+
`Retry from a non-blocked network or sync with an HTML fixture.`);
|
|
62
|
+
}
|
|
63
|
+
const rows = parseFbrefSchedule(html);
|
|
64
|
+
if (rows.length === 0) {
|
|
65
|
+
throw new Error(`No schedule rows parsed from FBref page: ${url}`);
|
|
66
|
+
}
|
|
67
|
+
return mapFbrefScheduleToSyncResult(pilot, rows);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function listFbrefPilotNames() {
|
|
71
|
+
return PILOTS.map((p) => p.name);
|
|
72
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { LineupEntry, Player, PlayerMatchStats } from "../types.js";
|
|
2
|
+
interface SbCard {
|
|
3
|
+
time?: string;
|
|
4
|
+
card_type?: string;
|
|
5
|
+
reason?: string;
|
|
6
|
+
period?: number;
|
|
7
|
+
}
|
|
8
|
+
interface SbPosition {
|
|
9
|
+
from?: string | null;
|
|
10
|
+
to?: string | null;
|
|
11
|
+
}
|
|
12
|
+
interface SbLineupPlayer {
|
|
13
|
+
player_id: number;
|
|
14
|
+
player_name: string;
|
|
15
|
+
player_nickname?: string | null;
|
|
16
|
+
jersey_number?: number | null;
|
|
17
|
+
country?: {
|
|
18
|
+
name?: string;
|
|
19
|
+
};
|
|
20
|
+
cards?: SbCard[];
|
|
21
|
+
positions?: SbPosition[];
|
|
22
|
+
}
|
|
23
|
+
interface SbLineupTeam {
|
|
24
|
+
team_id: number;
|
|
25
|
+
team_name: string;
|
|
26
|
+
lineup: SbLineupPlayer[];
|
|
27
|
+
}
|
|
28
|
+
interface SbEvent {
|
|
29
|
+
type?: {
|
|
30
|
+
name?: string;
|
|
31
|
+
};
|
|
32
|
+
player?: {
|
|
33
|
+
id?: number;
|
|
34
|
+
name?: string;
|
|
35
|
+
};
|
|
36
|
+
team?: {
|
|
37
|
+
id?: number;
|
|
38
|
+
name?: string;
|
|
39
|
+
};
|
|
40
|
+
shot?: {
|
|
41
|
+
outcome?: {
|
|
42
|
+
name?: string;
|
|
43
|
+
};
|
|
44
|
+
key_pass_id?: string;
|
|
45
|
+
};
|
|
46
|
+
pass?: {
|
|
47
|
+
goal_assist?: boolean;
|
|
48
|
+
};
|
|
49
|
+
id?: string;
|
|
50
|
+
}
|
|
51
|
+
/** Approximate minutes played from StatsBomb lineup position intervals. */
|
|
52
|
+
export declare function minutesFromPositions(positions: SbPosition[] | undefined): number | null;
|
|
53
|
+
export interface AggregatedPlayerMatch {
|
|
54
|
+
players: Player[];
|
|
55
|
+
playerMatchStats: PlayerMatchStats[];
|
|
56
|
+
lineups: LineupEntry[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build Player + PlayerMatchStats + LineupEntry for one match
|
|
60
|
+
* from StatsBomb lineups + events.
|
|
61
|
+
*/
|
|
62
|
+
export declare function aggregateStatsBombPlayerMatch(matchIdCampo: string, lineups: SbLineupTeam[], events: SbEvent[]): AggregatedPlayerMatch;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { entityId } from "../ids.js";
|
|
2
|
+
const SOURCE = "statsbomb";
|
|
3
|
+
function parseClockToMinutes(clock) {
|
|
4
|
+
if (!clock)
|
|
5
|
+
return null;
|
|
6
|
+
const m = clock.match(/^(\d+):(\d+)/);
|
|
7
|
+
if (!m)
|
|
8
|
+
return null;
|
|
9
|
+
return Number(m[1]) + Number(m[2]) / 60;
|
|
10
|
+
}
|
|
11
|
+
/** Approximate minutes played from StatsBomb lineup position intervals. */
|
|
12
|
+
export function minutesFromPositions(positions) {
|
|
13
|
+
if (!positions || positions.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
let total = 0;
|
|
16
|
+
let any = false;
|
|
17
|
+
for (const pos of positions) {
|
|
18
|
+
const from = parseClockToMinutes(pos.from);
|
|
19
|
+
const to = parseClockToMinutes(pos.to ?? "90:00");
|
|
20
|
+
if (from == null || to == null)
|
|
21
|
+
continue;
|
|
22
|
+
any = true;
|
|
23
|
+
total += Math.max(0, to - from);
|
|
24
|
+
}
|
|
25
|
+
return any ? Math.round(total) : null;
|
|
26
|
+
}
|
|
27
|
+
function countCards(cards) {
|
|
28
|
+
let yellow = 0;
|
|
29
|
+
let red = 0;
|
|
30
|
+
for (const card of cards ?? []) {
|
|
31
|
+
const t = (card.card_type ?? "").toLowerCase();
|
|
32
|
+
if (t.includes("second yellow") || t.includes("red"))
|
|
33
|
+
red += 1;
|
|
34
|
+
else if (t.includes("yellow"))
|
|
35
|
+
yellow += 1;
|
|
36
|
+
}
|
|
37
|
+
return { yellow, red };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build Player + PlayerMatchStats + LineupEntry for one match
|
|
41
|
+
* from StatsBomb lineups + events.
|
|
42
|
+
*/
|
|
43
|
+
export function aggregateStatsBombPlayerMatch(matchIdCampo, lineups, events) {
|
|
44
|
+
const goals = new Map();
|
|
45
|
+
const assists = new Map();
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
const playerId = event.player?.id;
|
|
48
|
+
if (playerId == null)
|
|
49
|
+
continue;
|
|
50
|
+
if (event.type?.name === "Shot" && event.shot?.outcome?.name === "Goal") {
|
|
51
|
+
goals.set(playerId, (goals.get(playerId) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
if (event.type?.name === "Pass" && event.pass?.goal_assist) {
|
|
54
|
+
assists.set(playerId, (assists.get(playerId) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const players = new Map();
|
|
58
|
+
const stats = [];
|
|
59
|
+
const lineupEntries = [];
|
|
60
|
+
const matchNative = matchIdCampo.split(":").pop();
|
|
61
|
+
for (const team of lineups) {
|
|
62
|
+
const teamId = entityId(SOURCE, "team", team.team_id);
|
|
63
|
+
for (const p of team.lineup) {
|
|
64
|
+
const playerId = entityId(SOURCE, "player", p.player_id);
|
|
65
|
+
players.set(playerId, {
|
|
66
|
+
id: playerId,
|
|
67
|
+
name: p.player_name,
|
|
68
|
+
nickname: p.player_nickname ?? undefined,
|
|
69
|
+
country: p.country?.name,
|
|
70
|
+
sources: [{ source: SOURCE, id: String(p.player_id) }],
|
|
71
|
+
});
|
|
72
|
+
const cards = countCards(p.cards);
|
|
73
|
+
const started = (p.positions?.length ?? 0) > 0;
|
|
74
|
+
stats.push({
|
|
75
|
+
id: entityId(SOURCE, "pstats", matchNative, p.player_id),
|
|
76
|
+
matchId: matchIdCampo,
|
|
77
|
+
playerId,
|
|
78
|
+
teamId,
|
|
79
|
+
minutes: minutesFromPositions(p.positions),
|
|
80
|
+
goals: goals.get(p.player_id) ?? 0,
|
|
81
|
+
assists: assists.get(p.player_id) ?? 0,
|
|
82
|
+
yellowCards: cards.yellow,
|
|
83
|
+
redCards: cards.red,
|
|
84
|
+
sources: [
|
|
85
|
+
{
|
|
86
|
+
source: SOURCE,
|
|
87
|
+
id: `${matchNative}:${p.player_id}`,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
lineupEntries.push({
|
|
92
|
+
id: entityId(SOURCE, "lineup", matchNative, p.player_id),
|
|
93
|
+
matchId: matchIdCampo,
|
|
94
|
+
teamId,
|
|
95
|
+
playerId,
|
|
96
|
+
started,
|
|
97
|
+
jerseyNumber: p.jersey_number ?? null,
|
|
98
|
+
sources: [{ source: SOURCE, id: `${matchNative}:${p.player_id}` }],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
players: [...players.values()],
|
|
104
|
+
playerMatchStats: stats,
|
|
105
|
+
lineups: lineupEntries,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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 type { LineupEntry, Match, Player, PlayerMatchStats } from "../types.js";
|
|
13
|
+
import type { FootballSource, SyncResult } from "./types.js";
|
|
14
|
+
export interface WomenCompetitionInfo {
|
|
15
|
+
id: number;
|
|
16
|
+
name: string;
|
|
17
|
+
country: string;
|
|
18
|
+
international: boolean;
|
|
19
|
+
seasons: Array<{
|
|
20
|
+
id: number;
|
|
21
|
+
name: string;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export interface StatsBombSourceOptions {
|
|
25
|
+
/** Override open-data root (default: GitHub raw master/data). */
|
|
26
|
+
baseUrl?: string;
|
|
27
|
+
/** Inject fetch for offline fixtures / tests. */
|
|
28
|
+
fetchJson?: <T>(url: string) => Promise<T>;
|
|
29
|
+
/** When true, also pull lineups/events and aggregate v1 player stats. */
|
|
30
|
+
includePlayerStats?: boolean;
|
|
31
|
+
/** Cap matches enriched with player stats (default 5). */
|
|
32
|
+
playerStatsLimit?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare class StatsBombSource implements FootballSource {
|
|
35
|
+
readonly id = "statsbomb";
|
|
36
|
+
private readonly baseUrl;
|
|
37
|
+
private readonly fetchJson;
|
|
38
|
+
private readonly includePlayerStats;
|
|
39
|
+
private readonly playerStatsLimit;
|
|
40
|
+
constructor(options?: StatsBombSourceOptions);
|
|
41
|
+
/** Catalogue of women's competitions (clubs + national-team tournaments). */
|
|
42
|
+
listWomenCompetitions(): Promise<WomenCompetitionInfo[]>;
|
|
43
|
+
syncCompetition(competitionName: string): Promise<SyncResult>;
|
|
44
|
+
/** Load aggregated v1 player stats + lineups for the given canonical matches. */
|
|
45
|
+
loadPlayerStatsForMatches(matches: Match[]): Promise<{
|
|
46
|
+
players: Player[];
|
|
47
|
+
playerMatchStats: PlayerMatchStats[];
|
|
48
|
+
lineups: LineupEntry[];
|
|
49
|
+
}>;
|
|
50
|
+
}
|