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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CampusCache } from "../types.js";
|
|
2
|
+
import type { SyncResult } from "../sources/types.js";
|
|
3
|
+
export type StoreMode = "json" | "sqlite";
|
|
4
|
+
export declare function resolveStoreMode(args: string[]): StoreMode;
|
|
5
|
+
export declare function resolveDbPath(args: string[], cwd?: string): string;
|
|
6
|
+
export declare function readStore(mode: StoreMode, dbPath: string): Promise<CampusCache>;
|
|
7
|
+
export declare function writeSyncToStore(mode: StoreMode, dbPath: string, result: SyncResult): Promise<CampusCache>;
|
|
8
|
+
export declare function writeCacheToStore(mode: StoreMode, dbPath: string, cache: CampusCache): Promise<void>;
|
|
9
|
+
export declare function runMigrate(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { loadCache, saveCache, cachePath, mergeSyncResult } from "../cache.js";
|
|
3
|
+
import { DEFAULT_DB_PATH, migrateJsonCacheToSqlite } from "./migrate.js";
|
|
4
|
+
import { SqliteStore } from "./sqlite-store.js";
|
|
5
|
+
export function resolveStoreMode(args) {
|
|
6
|
+
if (args.includes("--json"))
|
|
7
|
+
return "json";
|
|
8
|
+
if (args.includes("--sqlite") || getArg(args, "--db"))
|
|
9
|
+
return "sqlite";
|
|
10
|
+
return "json";
|
|
11
|
+
}
|
|
12
|
+
export function resolveDbPath(args, cwd = process.cwd()) {
|
|
13
|
+
const raw = getArg(args, "--db") ?? DEFAULT_DB_PATH;
|
|
14
|
+
return path.isAbsolute(raw) ? raw : path.join(cwd, raw);
|
|
15
|
+
}
|
|
16
|
+
function getArg(args, name) {
|
|
17
|
+
const idx = args.indexOf(name);
|
|
18
|
+
if (idx === -1)
|
|
19
|
+
return undefined;
|
|
20
|
+
const value = args[idx + 1];
|
|
21
|
+
if (!value || value.startsWith("--"))
|
|
22
|
+
throw new Error(`Missing value for ${name}`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export async function readStore(mode, dbPath) {
|
|
26
|
+
if (mode === "json")
|
|
27
|
+
return loadCache();
|
|
28
|
+
const store = new SqliteStore(dbPath);
|
|
29
|
+
try {
|
|
30
|
+
return store.loadCache();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
store.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function writeSyncToStore(mode, dbPath, result) {
|
|
37
|
+
if (mode === "json") {
|
|
38
|
+
const cache = mergeSyncResult(await loadCache(), result);
|
|
39
|
+
await saveCache(cache);
|
|
40
|
+
return cache;
|
|
41
|
+
}
|
|
42
|
+
const store = new SqliteStore(dbPath);
|
|
43
|
+
try {
|
|
44
|
+
const existing = store.loadCache();
|
|
45
|
+
const merged = mergeSyncResult(existing, result);
|
|
46
|
+
store.upsertCache(merged);
|
|
47
|
+
return merged;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
store.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function writeCacheToStore(mode, dbPath, cache) {
|
|
54
|
+
if (mode === "json") {
|
|
55
|
+
await saveCache(cache);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const store = new SqliteStore(dbPath);
|
|
59
|
+
try {
|
|
60
|
+
store.replaceAll(cache);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
store.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function runMigrate(args) {
|
|
67
|
+
const dbPath = resolveDbPath(args);
|
|
68
|
+
const result = await migrateJsonCacheToSqlite(dbPath, cachePath());
|
|
69
|
+
console.log(JSON.stringify(result, null, 2));
|
|
70
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product helpers aimed at women's fantasy football apps
|
|
3
|
+
* (club leagues + national-team tournaments).
|
|
4
|
+
*/
|
|
5
|
+
import { type StatsBombSourceOptions, type WomenCompetitionInfo } from "./sources/statsbomb.js";
|
|
6
|
+
import type { SyncResult } from "./sources/types.js";
|
|
7
|
+
import type { CampusCache, Competition } from "./types.js";
|
|
8
|
+
export type { WomenCompetitionInfo };
|
|
9
|
+
/** Default cap when enriching player stats in fantasy sync. */
|
|
10
|
+
export declare const FANTASY_PLAYER_STATS_LIMIT = 25;
|
|
11
|
+
export interface FantasySyncOptions extends StatsBombSourceOptions {
|
|
12
|
+
/**
|
|
13
|
+
* When including player stats, only enrich matches from each competition's
|
|
14
|
+
* latest season (recommended for fantasy scoring without downloading every
|
|
15
|
+
* historical event file).
|
|
16
|
+
*/
|
|
17
|
+
latestSeasonPlayersOnly?: boolean;
|
|
18
|
+
/** Optional progress logger (CLI uses stderr). */
|
|
19
|
+
onProgress?: (message: string) => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* List every women's competition currently published in StatsBomb Open Data.
|
|
23
|
+
* Includes club leagues (Liga F, WSL, …) and national-team tournaments
|
|
24
|
+
* (Women's World Cup, UEFA Women's Euro).
|
|
25
|
+
*/
|
|
26
|
+
export declare function listWomenCompetitions(options?: StatsBombSourceOptions): Promise<WomenCompetitionInfo[]>;
|
|
27
|
+
/**
|
|
28
|
+
* One-shot sync of the full women's open-data catalogue — clubs + selecciones.
|
|
29
|
+
* This is the recommended bootstrap for a worldwide women's fantasy product.
|
|
30
|
+
*/
|
|
31
|
+
export declare function syncFantasyBundle(options?: FantasySyncOptions): Promise<SyncResult>;
|
|
32
|
+
/**
|
|
33
|
+
* Re-sync every competition already present in a local cache (refresh path).
|
|
34
|
+
*/
|
|
35
|
+
export declare function updateCachedCompetitions(cache: CampusCache, options?: FantasySyncOptions): Promise<SyncResult>;
|
|
36
|
+
export declare function competitionIsInternational(c: Competition): boolean;
|
package/dist/fantasy.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product helpers aimed at women's fantasy football apps
|
|
3
|
+
* (club leagues + national-team tournaments).
|
|
4
|
+
*/
|
|
5
|
+
import { emptyCache, mergeSyncResult } from "./cache.js";
|
|
6
|
+
import { StatsBombSource, } from "./sources/statsbomb.js";
|
|
7
|
+
/** Default cap when enriching player stats in fantasy sync. */
|
|
8
|
+
export const FANTASY_PLAYER_STATS_LIMIT = 25;
|
|
9
|
+
/**
|
|
10
|
+
* List every women's competition currently published in StatsBomb Open Data.
|
|
11
|
+
* Includes club leagues (Liga F, WSL, …) and national-team tournaments
|
|
12
|
+
* (Women's World Cup, UEFA Women's Euro).
|
|
13
|
+
*/
|
|
14
|
+
export async function listWomenCompetitions(options = {}) {
|
|
15
|
+
const source = new StatsBombSource(options);
|
|
16
|
+
return source.listWomenCompetitions();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One-shot sync of the full women's open-data catalogue — clubs + selecciones.
|
|
20
|
+
* This is the recommended bootstrap for a worldwide women's fantasy product.
|
|
21
|
+
*/
|
|
22
|
+
export async function syncFantasyBundle(options = {}) {
|
|
23
|
+
const { latestSeasonPlayersOnly = true, onProgress, includePlayerStats = false, playerStatsLimit = FANTASY_PLAYER_STATS_LIMIT, ...sourceOpts } = options;
|
|
24
|
+
const source = new StatsBombSource({
|
|
25
|
+
...sourceOpts,
|
|
26
|
+
includePlayerStats: false,
|
|
27
|
+
});
|
|
28
|
+
const catalogue = await source.listWomenCompetitions();
|
|
29
|
+
onProgress?.(`Fantasy bundle: ${catalogue.length} women's competitions (clubs + national teams)…`);
|
|
30
|
+
let merged = emptySyncResult();
|
|
31
|
+
for (const comp of catalogue) {
|
|
32
|
+
onProgress?.(` syncing ${comp.name}${comp.international ? " (national teams)" : ""}…`);
|
|
33
|
+
const slice = await source.syncCompetition(comp.name);
|
|
34
|
+
merged = mergeSyncResults(merged, slice);
|
|
35
|
+
}
|
|
36
|
+
if (includePlayerStats) {
|
|
37
|
+
const enrichSource = new StatsBombSource({
|
|
38
|
+
...sourceOpts,
|
|
39
|
+
includePlayerStats: true,
|
|
40
|
+
playerStatsLimit,
|
|
41
|
+
});
|
|
42
|
+
const targets = pickMatchesForPlayerStats(merged, {
|
|
43
|
+
latestSeasonOnly: latestSeasonPlayersOnly,
|
|
44
|
+
limit: playerStatsLimit,
|
|
45
|
+
});
|
|
46
|
+
onProgress?.(` enriching player stats for ${targets.length} matches (fantasy metrics)…`);
|
|
47
|
+
const players = await enrichSource.loadPlayerStatsForMatches(targets);
|
|
48
|
+
merged.players = players.players;
|
|
49
|
+
merged.playerMatchStats = players.playerMatchStats;
|
|
50
|
+
merged.lineups = players.lineups;
|
|
51
|
+
}
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Re-sync every competition already present in a local cache (refresh path).
|
|
56
|
+
*/
|
|
57
|
+
export async function updateCachedCompetitions(cache, options = {}) {
|
|
58
|
+
const names = [...new Set(cache.competitions.map((c) => c.name))];
|
|
59
|
+
if (names.length === 0) {
|
|
60
|
+
return syncFantasyBundle(options);
|
|
61
|
+
}
|
|
62
|
+
const { onProgress, includePlayerStats = false, playerStatsLimit = FANTASY_PLAYER_STATS_LIMIT, latestSeasonPlayersOnly = true, ...sourceOpts } = options;
|
|
63
|
+
const source = new StatsBombSource({
|
|
64
|
+
...sourceOpts,
|
|
65
|
+
includePlayerStats: false,
|
|
66
|
+
});
|
|
67
|
+
let merged = emptySyncResult();
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
onProgress?.(` updating ${name}…`);
|
|
70
|
+
const slice = await source.syncCompetition(name);
|
|
71
|
+
merged = mergeSyncResults(merged, slice);
|
|
72
|
+
}
|
|
73
|
+
if (includePlayerStats) {
|
|
74
|
+
const enrichSource = new StatsBombSource({
|
|
75
|
+
...sourceOpts,
|
|
76
|
+
includePlayerStats: true,
|
|
77
|
+
playerStatsLimit,
|
|
78
|
+
});
|
|
79
|
+
const targets = pickMatchesForPlayerStats(merged, {
|
|
80
|
+
latestSeasonOnly: latestSeasonPlayersOnly,
|
|
81
|
+
limit: playerStatsLimit,
|
|
82
|
+
});
|
|
83
|
+
onProgress?.(` enriching player stats for ${targets.length} matches…`);
|
|
84
|
+
const players = await enrichSource.loadPlayerStatsForMatches(targets);
|
|
85
|
+
merged.players = players.players;
|
|
86
|
+
merged.playerMatchStats = players.playerMatchStats;
|
|
87
|
+
merged.lineups = players.lineups;
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
export function competitionIsInternational(c) {
|
|
92
|
+
return c.international === true;
|
|
93
|
+
}
|
|
94
|
+
function emptySyncResult() {
|
|
95
|
+
return {
|
|
96
|
+
competitions: [],
|
|
97
|
+
seasons: [],
|
|
98
|
+
teams: [],
|
|
99
|
+
matches: [],
|
|
100
|
+
players: [],
|
|
101
|
+
playerMatchStats: [],
|
|
102
|
+
lineups: [],
|
|
103
|
+
injuries: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function mergeSyncResults(a, b) {
|
|
107
|
+
const cache = mergeSyncResult(mergeSyncResult(emptyCache(), {
|
|
108
|
+
...a,
|
|
109
|
+
players: a.players ?? [],
|
|
110
|
+
playerMatchStats: a.playerMatchStats ?? [],
|
|
111
|
+
lineups: a.lineups ?? [],
|
|
112
|
+
injuries: a.injuries ?? [],
|
|
113
|
+
}), {
|
|
114
|
+
...b,
|
|
115
|
+
players: b.players ?? [],
|
|
116
|
+
playerMatchStats: b.playerMatchStats ?? [],
|
|
117
|
+
lineups: b.lineups ?? [],
|
|
118
|
+
injuries: b.injuries ?? [],
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
competitions: cache.competitions,
|
|
122
|
+
seasons: cache.seasons,
|
|
123
|
+
teams: cache.teams,
|
|
124
|
+
matches: cache.matches,
|
|
125
|
+
players: cache.players,
|
|
126
|
+
playerMatchStats: cache.playerMatchStats,
|
|
127
|
+
lineups: cache.lineups,
|
|
128
|
+
injuries: cache.injuries,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function pickMatchesForPlayerStats(result, opts) {
|
|
132
|
+
const seasonsByComp = new Map();
|
|
133
|
+
for (const s of result.seasons) {
|
|
134
|
+
const list = seasonsByComp.get(s.competitionId) ?? [];
|
|
135
|
+
list.push(s);
|
|
136
|
+
seasonsByComp.set(s.competitionId, list);
|
|
137
|
+
}
|
|
138
|
+
const latestSeasonIds = new Set();
|
|
139
|
+
if (opts.latestSeasonOnly) {
|
|
140
|
+
for (const [, seasons] of seasonsByComp) {
|
|
141
|
+
// Season names are chronological enough for open-data labels; prefer last by name.
|
|
142
|
+
const sorted = [...seasons].sort((a, b) => a.name.localeCompare(b.name));
|
|
143
|
+
const latest = sorted[sorted.length - 1];
|
|
144
|
+
if (latest)
|
|
145
|
+
latestSeasonIds.add(latest.id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const candidates = result.matches
|
|
149
|
+
.filter((m) => (opts.latestSeasonOnly ? latestSeasonIds.has(m.seasonId) : true))
|
|
150
|
+
.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? ""));
|
|
151
|
+
return candidates.slice(0, opts.limit);
|
|
152
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polite HTTP helper for HTML sources (FBref, etc.).
|
|
3
|
+
* Sequential requests share a minimum delay; failed GETs retry with backoff.
|
|
4
|
+
*/
|
|
5
|
+
export interface HttpClientOptions {
|
|
6
|
+
userAgent?: string;
|
|
7
|
+
/** Minimum ms between request starts (default 1500). */
|
|
8
|
+
minIntervalMs?: number;
|
|
9
|
+
/** Max attempts including the first (default 3). */
|
|
10
|
+
maxAttempts?: number;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
sleep?: (ms: number) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare class HttpClient {
|
|
15
|
+
private readonly userAgent;
|
|
16
|
+
private readonly minIntervalMs;
|
|
17
|
+
private readonly maxAttempts;
|
|
18
|
+
private readonly fetchImpl;
|
|
19
|
+
private readonly sleep;
|
|
20
|
+
private chain;
|
|
21
|
+
private lastStartedAt;
|
|
22
|
+
constructor(options?: HttpClientOptions);
|
|
23
|
+
getText(url: string): Promise<string>;
|
|
24
|
+
private scheduledGet;
|
|
25
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polite HTTP helper for HTML sources (FBref, etc.).
|
|
3
|
+
* Sequential requests share a minimum delay; failed GETs retry with backoff.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_UA = "campus/0.0.1 (women's football research; +https://gitlab.com/marina34/campus)";
|
|
6
|
+
function defaultSleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
export class HttpClient {
|
|
10
|
+
userAgent;
|
|
11
|
+
minIntervalMs;
|
|
12
|
+
maxAttempts;
|
|
13
|
+
fetchImpl;
|
|
14
|
+
sleep;
|
|
15
|
+
chain = Promise.resolve();
|
|
16
|
+
lastStartedAt = 0;
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.userAgent = options.userAgent ?? DEFAULT_UA;
|
|
19
|
+
this.minIntervalMs = options.minIntervalMs ?? 1500;
|
|
20
|
+
this.maxAttempts = options.maxAttempts ?? 3;
|
|
21
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
22
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
23
|
+
}
|
|
24
|
+
async getText(url) {
|
|
25
|
+
let lastError;
|
|
26
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
return await this.scheduledGet(url);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
lastError = err;
|
|
32
|
+
if (attempt === this.maxAttempts)
|
|
33
|
+
break;
|
|
34
|
+
const backoff = this.minIntervalMs * attempt;
|
|
35
|
+
await this.sleep(backoff);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw lastError instanceof Error
|
|
39
|
+
? lastError
|
|
40
|
+
: new Error(`HTTP GET failed for ${url}`);
|
|
41
|
+
}
|
|
42
|
+
scheduledGet(url) {
|
|
43
|
+
const run = this.chain.then(async () => {
|
|
44
|
+
const wait = Math.max(0, this.minIntervalMs - (Date.now() - this.lastStartedAt));
|
|
45
|
+
if (wait > 0)
|
|
46
|
+
await this.sleep(wait);
|
|
47
|
+
this.lastStartedAt = Date.now();
|
|
48
|
+
const res = await this.fetchImpl(url, {
|
|
49
|
+
headers: {
|
|
50
|
+
"user-agent": this.userAgent,
|
|
51
|
+
accept: "text/html,application/xhtml+xml",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
56
|
+
}
|
|
57
|
+
return res.text();
|
|
58
|
+
});
|
|
59
|
+
// Keep the queue alive even if a request fails.
|
|
60
|
+
this.chain = run.then(() => undefined, () => undefined);
|
|
61
|
+
return run;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Team } from "../types.js";
|
|
2
|
+
import type { CanonicalIdentity, IdentityConfidence } from "./types.js";
|
|
3
|
+
/** Normalize club names for comparison across providers. */
|
|
4
|
+
export declare function normalizeTeamName(name: string): string;
|
|
5
|
+
export interface TeamMatchCandidate {
|
|
6
|
+
left: Team;
|
|
7
|
+
right: Team;
|
|
8
|
+
score: number;
|
|
9
|
+
confidence: IdentityConfidence;
|
|
10
|
+
}
|
|
11
|
+
/** Dice coefficient on normalized tokens. */
|
|
12
|
+
export declare function nameSimilarity(a: string, b: string): number;
|
|
13
|
+
/**
|
|
14
|
+
* Propose team matches between two source cohorts.
|
|
15
|
+
* `left` / `right` are typically teams that appear in the same competition
|
|
16
|
+
* context but come from different providers.
|
|
17
|
+
*/
|
|
18
|
+
export declare function matchTeamsAcrossSources(left: Team[], right: Team[], options?: {
|
|
19
|
+
competitionHint?: string;
|
|
20
|
+
minScore?: number;
|
|
21
|
+
}): TeamMatchCandidate[];
|
|
22
|
+
export declare function candidateToIdentity(candidate: TeamMatchCandidate, competitionHint?: string): CanonicalIdentity;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { entityId } from "../ids.js";
|
|
2
|
+
const NOISE = new Set([
|
|
3
|
+
"w",
|
|
4
|
+
"wfc",
|
|
5
|
+
"fc",
|
|
6
|
+
"cf",
|
|
7
|
+
"afc",
|
|
8
|
+
"women",
|
|
9
|
+
"women's",
|
|
10
|
+
"womens",
|
|
11
|
+
"lady",
|
|
12
|
+
"ladies",
|
|
13
|
+
"club",
|
|
14
|
+
"the",
|
|
15
|
+
]);
|
|
16
|
+
/** Normalize club names for comparison across providers. */
|
|
17
|
+
export function normalizeTeamName(name) {
|
|
18
|
+
return name
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.normalize("NFD")
|
|
21
|
+
.replace(/\p{M}/gu, "")
|
|
22
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter((t) => t && !NOISE.has(t))
|
|
25
|
+
.join(" ")
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
function tokenSet(name) {
|
|
29
|
+
return new Set(normalizeTeamName(name).split(" ").filter(Boolean));
|
|
30
|
+
}
|
|
31
|
+
/** Dice coefficient on normalized tokens. */
|
|
32
|
+
export function nameSimilarity(a, b) {
|
|
33
|
+
const na = normalizeTeamName(a);
|
|
34
|
+
const nb = normalizeTeamName(b);
|
|
35
|
+
if (!na || !nb)
|
|
36
|
+
return 0;
|
|
37
|
+
if (na === nb)
|
|
38
|
+
return 1;
|
|
39
|
+
const ta = tokenSet(a);
|
|
40
|
+
const tb = tokenSet(b);
|
|
41
|
+
if (ta.size === 0 || tb.size === 0)
|
|
42
|
+
return 0;
|
|
43
|
+
let inter = 0;
|
|
44
|
+
for (const t of ta)
|
|
45
|
+
if (tb.has(t))
|
|
46
|
+
inter += 1;
|
|
47
|
+
return (2 * inter) / (ta.size + tb.size);
|
|
48
|
+
}
|
|
49
|
+
function confidenceFor(score) {
|
|
50
|
+
if (score >= 0.9)
|
|
51
|
+
return "high";
|
|
52
|
+
if (score >= 0.7)
|
|
53
|
+
return "medium";
|
|
54
|
+
return "low";
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Propose team matches between two source cohorts.
|
|
58
|
+
* `left` / `right` are typically teams that appear in the same competition
|
|
59
|
+
* context but come from different providers.
|
|
60
|
+
*/
|
|
61
|
+
export function matchTeamsAcrossSources(left, right, options = {}) {
|
|
62
|
+
const minScore = options.minScore ?? 0.7;
|
|
63
|
+
const usedRight = new Set();
|
|
64
|
+
const candidates = [];
|
|
65
|
+
for (const l of left) {
|
|
66
|
+
let best;
|
|
67
|
+
for (const r of right) {
|
|
68
|
+
if (usedRight.has(r.id))
|
|
69
|
+
continue;
|
|
70
|
+
// Never link two refs from the same provider key as a "cross-source" hit.
|
|
71
|
+
const lSources = new Set(l.sources.map((s) => s.source));
|
|
72
|
+
if (r.sources.some((s) => lSources.has(s.source)))
|
|
73
|
+
continue;
|
|
74
|
+
const score = nameSimilarity(l.name, r.name);
|
|
75
|
+
if (score < minScore)
|
|
76
|
+
continue;
|
|
77
|
+
const cand = {
|
|
78
|
+
left: l,
|
|
79
|
+
right: r,
|
|
80
|
+
score,
|
|
81
|
+
confidence: confidenceFor(score),
|
|
82
|
+
};
|
|
83
|
+
if (!best || cand.score > best.score)
|
|
84
|
+
best = cand;
|
|
85
|
+
}
|
|
86
|
+
if (best) {
|
|
87
|
+
usedRight.add(best.right.id);
|
|
88
|
+
candidates.push(best);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return candidates.sort((a, b) => b.score - a.score);
|
|
92
|
+
}
|
|
93
|
+
export function candidateToIdentity(candidate, competitionHint) {
|
|
94
|
+
const slug = normalizeTeamName(candidate.left.name).replace(/\s+/g, "-") || "team";
|
|
95
|
+
return {
|
|
96
|
+
id: entityId("identity", "team", slug),
|
|
97
|
+
kind: "team",
|
|
98
|
+
name: candidate.left.name,
|
|
99
|
+
aliases: [...new Set([candidate.left.name, candidate.right.name])],
|
|
100
|
+
sources: [...candidate.left.sources, ...candidate.right.sources],
|
|
101
|
+
confidence: candidate.confidence,
|
|
102
|
+
// Status filled by rule layer; default pending here.
|
|
103
|
+
status: "pending",
|
|
104
|
+
competitionHint,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CampusCache, Team } from "../types.js";
|
|
2
|
+
/** Split cached teams by primary provenance source. */
|
|
3
|
+
export declare function partitionTeamsBySource(teams: Team[]): Map<string, Team[]>;
|
|
4
|
+
/**
|
|
5
|
+
* Propose identities between the first two source cohorts present for a
|
|
6
|
+
* competition (typically statsbomb vs fbref).
|
|
7
|
+
*/
|
|
8
|
+
export declare function proposeIdentitiesForCompetition(cache: CampusCache, competitionName: string): CampusCache;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { matchTeamsAcrossSources } from "./match.js";
|
|
2
|
+
import { applyIdentityRules } from "./rules.js";
|
|
3
|
+
import { upsertIdentities } from "./store.js";
|
|
4
|
+
function teamsForCompetition(cache, competitionName) {
|
|
5
|
+
const competition = cache.competitions.find((c) => c.name.toLowerCase() === competitionName.toLowerCase());
|
|
6
|
+
if (!competition)
|
|
7
|
+
return [];
|
|
8
|
+
const ids = new Set();
|
|
9
|
+
for (const m of cache.matches) {
|
|
10
|
+
if (m.competitionId !== competition.id)
|
|
11
|
+
continue;
|
|
12
|
+
ids.add(m.homeTeamId);
|
|
13
|
+
ids.add(m.awayTeamId);
|
|
14
|
+
}
|
|
15
|
+
return cache.teams.filter((t) => ids.has(t.id));
|
|
16
|
+
}
|
|
17
|
+
/** Split cached teams by primary provenance source. */
|
|
18
|
+
export function partitionTeamsBySource(teams) {
|
|
19
|
+
const map = new Map();
|
|
20
|
+
for (const team of teams) {
|
|
21
|
+
const source = team.sources[0]?.source ?? "unknown";
|
|
22
|
+
const list = map.get(source) ?? [];
|
|
23
|
+
list.push(team);
|
|
24
|
+
map.set(source, list);
|
|
25
|
+
}
|
|
26
|
+
return map;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Propose identities between the first two source cohorts present for a
|
|
30
|
+
* competition (typically statsbomb vs fbref).
|
|
31
|
+
*/
|
|
32
|
+
export function proposeIdentitiesForCompetition(cache, competitionName) {
|
|
33
|
+
const teams = teamsForCompetition(cache, competitionName);
|
|
34
|
+
const bySource = [...partitionTeamsBySource(teams).entries()];
|
|
35
|
+
if (bySource.length < 2) {
|
|
36
|
+
throw new Error(`Need teams from at least two sources in cache for "${competitionName}" before proposing identities.`);
|
|
37
|
+
}
|
|
38
|
+
const [, left] = bySource[0];
|
|
39
|
+
const [, right] = bySource[1];
|
|
40
|
+
const candidates = matchTeamsAcrossSources(left, right, {
|
|
41
|
+
competitionHint: competitionName,
|
|
42
|
+
});
|
|
43
|
+
const identities = applyIdentityRules(candidates, competitionName);
|
|
44
|
+
return upsertIdentities(cache, identities);
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CanonicalIdentity } from "./types.js";
|
|
2
|
+
import { type TeamMatchCandidate } from "./match.js";
|
|
3
|
+
/**
|
|
4
|
+
* Auto-resolve only high-confidence exact-ish matches.
|
|
5
|
+
* Medium/low stay `pending` for review — never silent merge.
|
|
6
|
+
*/
|
|
7
|
+
export declare function applyIdentityRules(candidates: TeamMatchCandidate[], competitionHint?: string): CanonicalIdentity[];
|
|
8
|
+
export declare function isQueryableIdentity(identity: CanonicalIdentity): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { candidateToIdentity, } from "./match.js";
|
|
2
|
+
/**
|
|
3
|
+
* Auto-resolve only high-confidence exact-ish matches.
|
|
4
|
+
* Medium/low stay `pending` for review — never silent merge.
|
|
5
|
+
*/
|
|
6
|
+
export function applyIdentityRules(candidates, competitionHint) {
|
|
7
|
+
return candidates.map((c) => {
|
|
8
|
+
const identity = candidateToIdentity(c, competitionHint);
|
|
9
|
+
if (c.confidence === "high" && c.score >= 0.95) {
|
|
10
|
+
return { ...identity, status: "resolved" };
|
|
11
|
+
}
|
|
12
|
+
return { ...identity, status: "pending", confidence: c.confidence };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function isQueryableIdentity(identity) {
|
|
16
|
+
return identity.status === "resolved";
|
|
17
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CampusCache } from "../types.js";
|
|
2
|
+
import type { CanonicalIdentity } from "./types.js";
|
|
3
|
+
export declare function mergeIdentities(existing: CanonicalIdentity[], incoming: CanonicalIdentity[]): CanonicalIdentity[];
|
|
4
|
+
export declare function upsertIdentities(cache: CampusCache, identities: CanonicalIdentity[]): CampusCache;
|
|
5
|
+
export declare function setIdentityStatus(cache: CampusCache, identityId: string, status: CanonicalIdentity["status"]): CampusCache;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function mergeIdentities(existing, incoming) {
|
|
2
|
+
const map = new Map();
|
|
3
|
+
for (const item of existing)
|
|
4
|
+
map.set(item.id, item);
|
|
5
|
+
for (const item of incoming)
|
|
6
|
+
map.set(item.id, item);
|
|
7
|
+
return [...map.values()];
|
|
8
|
+
}
|
|
9
|
+
export function upsertIdentities(cache, identities) {
|
|
10
|
+
return {
|
|
11
|
+
...cache,
|
|
12
|
+
identities: mergeIdentities(cache.identities, identities),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function setIdentityStatus(cache, identityId, status) {
|
|
16
|
+
return {
|
|
17
|
+
...cache,
|
|
18
|
+
identities: cache.identities.map((identity) => identity.id === identityId ? { ...identity, status } : identity),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-source identity model for campus.
|
|
3
|
+
*
|
|
4
|
+
* A CanonicalIdentity groups one or more provider-native SourceRefs that
|
|
5
|
+
* refer to the same real-world club (later: player). Confidence and status
|
|
6
|
+
* decide whether the link is safe to auto-apply in queries.
|
|
7
|
+
*/
|
|
8
|
+
import type { SourceRef } from "../refs.js";
|
|
9
|
+
export type IdentityKind = "team" | "player";
|
|
10
|
+
/** How sure we are that the linked source refs are the same entity. */
|
|
11
|
+
export type IdentityConfidence = "high" | "medium" | "low";
|
|
12
|
+
/**
|
|
13
|
+
* `resolved` — safe to treat as one entity in queries.
|
|
14
|
+
* `pending` — candidate needs human review; do not auto-merge.
|
|
15
|
+
* `rejected` — reviewed and kept separate.
|
|
16
|
+
*/
|
|
17
|
+
export type IdentityStatus = "resolved" | "pending" | "rejected";
|
|
18
|
+
export interface CanonicalIdentity {
|
|
19
|
+
/** Stable campus identity id, e.g. `identity:team:…`. */
|
|
20
|
+
id: string;
|
|
21
|
+
kind: IdentityKind;
|
|
22
|
+
/** Preferred display name. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** Extra names that should match this identity. */
|
|
25
|
+
aliases: string[];
|
|
26
|
+
/** Provider refs that belong to this identity. */
|
|
27
|
+
sources: SourceRef[];
|
|
28
|
+
confidence: IdentityConfidence;
|
|
29
|
+
status: IdentityStatus;
|
|
30
|
+
/** Optional competition context used when the match was proposed. */
|
|
31
|
+
competitionHint?: string;
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-source identity model for campus.
|
|
3
|
+
*
|
|
4
|
+
* A CanonicalIdentity groups one or more provider-native SourceRefs that
|
|
5
|
+
* refer to the same real-world club (later: player). Confidence and status
|
|
6
|
+
* decide whether the link is safe to auto-apply in queries.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/dist/ids.d.ts
ADDED
package/dist/ids.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type { SourceRef, TeamKind, Competition, Season, Team, Match, Player, PlayerMatchStats, LineupEntry, InjuryRecord, CampusCache, } from "./types.js";
|
|
2
|
+
export type { IdentityKind, IdentityConfidence, IdentityStatus, CanonicalIdentity, } from "./identity/types.js";
|
|
3
|
+
export type { FootballSource, SyncResult } from "./sources/types.js";
|
|
4
|
+
export { StatsBombSource } from "./sources/statsbomb.js";
|
|
5
|
+
export type { WomenCompetitionInfo } from "./sources/statsbomb.js";
|
|
6
|
+
export { FbrefSource, listFbrefPilotNames } from "./sources/fbref.js";
|
|
7
|
+
export { emptyCache, cachePath, loadCache, saveCache, mergeSyncResult, DEFAULT_CACHE_DIR, DEFAULT_CACHE_FILE, } from "./cache.js";
|
|
8
|
+
export { SqliteStore, SCHEMA_SQL } from "./db/sqlite-store.js";
|
|
9
|
+
export { FANTASY_PLAYER_STATS_LIMIT, listWomenCompetitions, syncFantasyBundle, updateCachedCompetitions, competitionIsInternational, } from "./fantasy.js";
|
|
10
|
+
export type { FantasySyncOptions } from "./fantasy.js";
|
|
11
|
+
export { GITLAB_PROJECT_ID, DATA_BUNDLE_PACKAGE, DATA_BUNDLE_VERSION, DATA_BUNDLE_FILE, dataBundleUrl, dataBundleMetaUrl, } from "./data-bundle.js";
|
|
12
|
+
export { CampusClient } from "./client.js";
|
|
13
|
+
export type { SquadMember } from "./client.js";
|
|
14
|
+
export { DEFAULT_FANTASY_RULES, scorePlayerMatchStats, scoreFantasyPoints, } from "./scoring.js";
|
|
15
|
+
export type { FantasyScoringRules, FantasyPointRow } from "./scoring.js";
|
|
16
|
+
export { INJURIES_AVAILABLE, INJURIES_STATUS_MESSAGE, listInjuries, } from "./injuries.js";
|