cito-mcp 0.4.4 → 0.4.6
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/README.md +7 -7
- package/dist/client.js +39 -35
- package/dist/envelope.js +3 -13
- package/dist/http.js +0 -16
- package/dist/index.js +0 -39
- package/dist/install.js +0 -44
- package/dist/instructions.js +76 -80
- package/dist/scrub.js +111 -0
- package/dist/tools/cs2.js +1 -48
- package/dist/tools/index.js +0 -11
- package/dist/tools/insight.js +42 -174
- package/dist/tools/leaderboard.js +0 -21
- package/dist/tools/live.js +36 -127
- package/dist/tools/match.js +29 -184
- package/dist/tools/meta.js +7 -52
- package/dist/tools/normalize.js +6 -229
- package/dist/tools/odds.js +0 -66
- package/dist/tools/player.js +45 -179
- package/dist/tools/rankings.js +0 -38
- package/dist/tools/resolve.js +0 -136
- package/dist/tools/schedule.js +0 -55
- package/dist/tools/standings.js +21 -65
- package/dist/tools/team.js +2 -83
- package/dist/tools/tournaments.js +0 -37
- package/dist/tools/types.js +0 -17
- package/dist/version.js +0 -17
- package/package.json +22 -2
package/dist/tools/normalize.js
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-game row normalization for live/schedule boards and entity cards.
|
|
3
|
-
*/
|
|
4
1
|
import { asRecord, pickString, unwrapPayload, DEFAULT_API_BASE } from '../client.js';
|
|
5
2
|
const IMAGE_PROXY_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
6
|
-
/**
|
|
7
|
-
* Bout rows carry raw ufc.com URLs but no proxied variant (only the fighter
|
|
8
|
-
* detail endpoint includes one). ufc.com can hotlink-block and sends no CORS
|
|
9
|
-
* header, so a browser-side card built straight off those URLs shows broken
|
|
10
|
-
* images. The proxy token is base64url of the source URL — verified to
|
|
11
|
-
* round-trip and serve HTTP 200 — so derive it rather than making the caller
|
|
12
|
-
* N+1 the fighter endpoint just to get a loadable image.
|
|
13
|
-
*
|
|
14
|
-
* UFC only: /public/images/lol/<token> returns HTTP 400, so other games get
|
|
15
|
-
* their raw URL and a null proxy rather than a fabricated link.
|
|
16
|
-
*/
|
|
17
3
|
function deriveProxiedImageUrl(rawUrl, game) {
|
|
18
4
|
if (!rawUrl || game !== 'ufc')
|
|
19
5
|
return null;
|
|
@@ -28,14 +14,6 @@ function deriveProxiedImageUrl(rawUrl, game) {
|
|
|
28
14
|
return null;
|
|
29
15
|
return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
|
|
30
16
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Tennis has players, not teams. The cross-game NormalizedMatch type keeps
|
|
33
|
-
* team1/team2 internally for every game so the rest of this module stays
|
|
34
|
-
* uniform (nestedSide, sidesMatch, sortByCardOrder, …); this renames those two
|
|
35
|
-
* keys — and, when present, a nested score sub-object shaped the same way —
|
|
36
|
-
* at the JSON boundary for tennis only. Every other game passes through
|
|
37
|
-
* unchanged.
|
|
38
|
-
*/
|
|
39
17
|
export function presentSides(obj, game) {
|
|
40
18
|
if (game !== 'tennis' || !obj || typeof obj !== 'object')
|
|
41
19
|
return obj;
|
|
@@ -62,28 +40,12 @@ function numOrNull(v) {
|
|
|
62
40
|
return Number(v);
|
|
63
41
|
return null;
|
|
64
42
|
}
|
|
65
|
-
/**
|
|
66
|
-
* One set of a tennis score string ("6-4", "7-6(6)", "6-4, 3-6") as games won.
|
|
67
|
-
*
|
|
68
|
-
* A tiebreak is parenthesised after the games ("7-6(6)"), so the regex stops at
|
|
69
|
-
* the games. Anything else returns null rather than a guess: retirement rows
|
|
70
|
-
* carry "6-3 2-1 RET", and reading "RET" as games would invent a set.
|
|
71
|
-
*/
|
|
72
43
|
export function parseTennisSetToken(token) {
|
|
73
44
|
const m = /^\s*(\d{1,2})\s*-\s*(\d{1,2})/.exec(token);
|
|
74
45
|
if (!m)
|
|
75
46
|
return null;
|
|
76
47
|
return { p1: Number(m[1]), p2: Number(m[2]) };
|
|
77
48
|
}
|
|
78
|
-
/**
|
|
79
|
-
* Sets won by one side, counted from the match-level game score
|
|
80
|
-
* ("4-6 6-3 6-3 7-5" → 3 for side 1, 1 for side 2).
|
|
81
|
-
*
|
|
82
|
-
* `score` orders every token as player1-player2 on this feed, which is verified
|
|
83
|
-
* against the same row's sets[] (set 1: score token "4-6", player1_games 4,
|
|
84
|
-
* player2_games 6). Ties are impossible in a completed set, so a token where
|
|
85
|
-
* neither side is ahead contributes to neither total.
|
|
86
|
-
*/
|
|
87
49
|
export function tennisSetsWonFromScore(score, side) {
|
|
88
50
|
if (!score)
|
|
89
51
|
return null;
|
|
@@ -103,15 +65,6 @@ export function tennisSetsWonFromScore(score, side) {
|
|
|
103
65
|
}
|
|
104
66
|
return parsed > 0 ? won : null;
|
|
105
67
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Sets won by one side, counted from the structured sets[] array.
|
|
108
|
-
*
|
|
109
|
-
* player1_games/player2_games are the ONLY side-correct pair here. The sibling
|
|
110
|
-
* winner_games/loser_games are oriented to the match winner, not to side 1 —
|
|
111
|
-
* on s365_2026_4849786 set 1 is `winner_games: 4, loser_games: 6` while side 1
|
|
112
|
-
* (the winner, Shelton) actually lost that set 4-6. Reading those would have
|
|
113
|
-
* produced a confidently wrong 1-3 scoreline.
|
|
114
|
-
*/
|
|
115
68
|
export function tennisSetsWonFromSets(sets, side) {
|
|
116
69
|
if (!Array.isArray(sets) || sets.length === 0)
|
|
117
70
|
return null;
|
|
@@ -133,14 +86,6 @@ export function tennisSetsWonFromSets(sets, side) {
|
|
|
133
86
|
}
|
|
134
87
|
return parsed > 0 ? won : null;
|
|
135
88
|
}
|
|
136
|
-
/**
|
|
137
|
-
* Whether a set row is finished.
|
|
138
|
-
*
|
|
139
|
-
* The live route states `is_completed`; the archive route omits the key
|
|
140
|
-
* entirely, and reading only the flag marked all four sets of a COMPLETED US
|
|
141
|
-
* Open semi-final `completed: false`. When the flag is absent the games decide:
|
|
142
|
-
* a set is over at 6-x with a two-game margin, or at 7-6/7-5.
|
|
143
|
-
*/
|
|
144
89
|
export function tennisSetCompleted(sr) {
|
|
145
90
|
if (typeof sr.is_completed === 'boolean')
|
|
146
91
|
return sr.is_completed;
|
|
@@ -152,30 +97,10 @@ export function tennisSetCompleted(sr) {
|
|
|
152
97
|
const lo = Math.min(p1, p2);
|
|
153
98
|
return (hi >= 6 && hi - lo >= 2) || (hi === 7 && lo === 6);
|
|
154
99
|
}
|
|
155
|
-
/**
|
|
156
|
-
* Sets won by one side of a tennis match, from whichever spelling the row uses.
|
|
157
|
-
*
|
|
158
|
-
* Four sources exist because three different endpoints describe the same match:
|
|
159
|
-
* 1. `sets_won` — the live route puts it on each player object
|
|
160
|
-
* 2. `winner_sets_won`/`loser_sets_won` — the archive route's own totals
|
|
161
|
-
* 3. `sets[]` — per-set games, present on both
|
|
162
|
-
* 4. the `score` string — "4-6 6-3 6-3 7-5", present on both
|
|
163
|
-
*
|
|
164
|
-
* The archive route (`/tennis/matches/{id}`) carries NO sets_won on its player
|
|
165
|
-
* objects at all, which is why a completed US Open semi-final rendered as
|
|
166
|
-
* "Ben Shelton - : - Frances Tiafoe" while the same response held the real
|
|
167
|
-
* score in `score` and in `sets[]`. Reading only source 1 is the bug; the order
|
|
168
|
-
* above is authority, not preference — a server-computed total beats a count we
|
|
169
|
-
* derive, and the structured array beats re-parsing a display string.
|
|
170
|
-
*/
|
|
171
100
|
export function tennisSetsWon(opts) {
|
|
172
101
|
const explicit = numOrNull(opts.sideSetsWon);
|
|
173
102
|
if (explicit !== null)
|
|
174
103
|
return explicit;
|
|
175
|
-
// winner_sets_won / loser_sets_won belong to a side, and which side is known
|
|
176
|
-
// either from the row's own is_winner flag or by matching ids. Without one of
|
|
177
|
-
// those signals the pair is unusable and the structured sets below are safer
|
|
178
|
-
// than a coin flip.
|
|
179
104
|
const wsw = numOrNull(opts.winnerSetsWon);
|
|
180
105
|
const lsw = numOrNull(opts.loserSetsWon);
|
|
181
106
|
if (wsw !== null && lsw !== null) {
|
|
@@ -194,12 +119,6 @@ export function tennisSetsWon(opts) {
|
|
|
194
119
|
return fromSets;
|
|
195
120
|
return tennisSetsWonFromScore(opts.score ?? undefined, opts.side);
|
|
196
121
|
}
|
|
197
|
-
/**
|
|
198
|
-
* Collect image URLs from a corner/team row and its nested profile. Returns
|
|
199
|
-
* undefined when the upstream carried none, so lean rows stay lean; when any
|
|
200
|
-
* image exists every key is present (null where absent) — a missing key would
|
|
201
|
-
* read as "this API has no images" rather than "no image for this one".
|
|
202
|
-
*/
|
|
203
122
|
function imagesFrom(game, ...sources) {
|
|
204
123
|
const pick = (...keys) => {
|
|
205
124
|
for (const src of sources) {
|
|
@@ -214,15 +133,12 @@ function imagesFrom(game, ...sources) {
|
|
|
214
133
|
return null;
|
|
215
134
|
};
|
|
216
135
|
const images = {
|
|
217
|
-
// portrait_url is the tennis feed's only likeness (see identityFrom in
|
|
218
|
-
// player.ts); without it a tennis side on every board carried no image.
|
|
219
136
|
headshotUrl: pick('headshotUrl', 'headshot', 'portrait_url'),
|
|
220
137
|
bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
|
|
221
138
|
imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo', 'portrait_url'),
|
|
222
139
|
proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
|
|
223
140
|
};
|
|
224
141
|
if (!images.proxiedImageUrl) {
|
|
225
|
-
// Prefer the headshot for the proxied variant — it is the crop a card UI wants.
|
|
226
142
|
images.proxiedImageUrl =
|
|
227
143
|
deriveProxiedImageUrl(images.headshotUrl, game) ??
|
|
228
144
|
deriveProxiedImageUrl(images.imageUrl, game);
|
|
@@ -249,7 +165,6 @@ function recordFrom(...sources) {
|
|
|
249
165
|
}
|
|
250
166
|
return undefined;
|
|
251
167
|
}
|
|
252
|
-
/** Enrichment shared by UFC corners and (where upstream supplies it) team rows. */
|
|
253
168
|
function sideExtras(o, game) {
|
|
254
169
|
const profile = asRecord(o.profile) ?? undefined;
|
|
255
170
|
const fighter = asRecord(o.fighter) ?? undefined;
|
|
@@ -259,8 +174,6 @@ function sideExtras(o, game) {
|
|
|
259
174
|
const rank = pickString(o.rankText, o.rank, profile?.rankText);
|
|
260
175
|
const championStatus = pickString(o.championStatus, profile?.championStatus);
|
|
261
176
|
const division = pickString(profile?.division, o.division, o.weightClass);
|
|
262
|
-
// `ioc` is the tennis feed's country code ("ESP"); it was not read, so every
|
|
263
|
-
// tennis side on every board came back country-less.
|
|
264
177
|
const country = pickString(o.country, profile?.country, o.ioc);
|
|
265
178
|
const flag = pickString(o.flag, profile?.flag);
|
|
266
179
|
const outcome = pickString(o.outcome);
|
|
@@ -269,12 +182,7 @@ function sideExtras(o, game) {
|
|
|
269
182
|
...(images ? { images } : {}),
|
|
270
183
|
...(record ? { record } : {}),
|
|
271
184
|
...(rank ? { rank } : {}),
|
|
272
|
-
// "none" carries no signal for a page; only surface an actual belt state.
|
|
273
185
|
...(championStatus && championStatus.toLowerCase() !== 'none' ? { championStatus } : {}),
|
|
274
|
-
// UFC corners always carry these keys. A debutant with no ranked division
|
|
275
|
-
// was shipping with the key ABSENT while every other corner had it, and a
|
|
276
|
-
// fighter from a country with no flag mapping did the same, which breaks
|
|
277
|
-
// strict parsers. Null says "not known"; absence says nothing.
|
|
278
186
|
...(game === 'ufc'
|
|
279
187
|
? { division: division ?? null, country: country ?? null, flag: flag ?? null }
|
|
280
188
|
: {
|
|
@@ -311,12 +219,6 @@ function scoreNum(v) {
|
|
|
311
219
|
return Number(v);
|
|
312
220
|
return null;
|
|
313
221
|
}
|
|
314
|
-
/**
|
|
315
|
-
* Collapse the six spellings one outcome arrives in ("SUB" / "Submission",
|
|
316
|
-
* "U-DEC" / "Decision - Unanimous", "KO/TKO" / "TKO") onto one enum. The raw
|
|
317
|
-
* string is kept alongside as methodRaw; this is a stable key for consumers,
|
|
318
|
-
* not a rewrite of the source.
|
|
319
|
-
*/
|
|
320
222
|
export function normalizeUfcMethod(raw) {
|
|
321
223
|
if (!raw)
|
|
322
224
|
return undefined;
|
|
@@ -346,15 +248,12 @@ export function normalizeUfcMethod(raw) {
|
|
|
346
248
|
return raw.trim();
|
|
347
249
|
}
|
|
348
250
|
export function normalizeMatch(game, row, forcedStatus) {
|
|
349
|
-
// Always peel { success, data } so UFC bout fighters[] / status are visible.
|
|
350
251
|
const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
|
|
351
|
-
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.
|
|
252
|
+
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.live_match_id) ?? 'unknown';
|
|
352
253
|
let team1 = nestedSide(r.team1, game) ??
|
|
353
254
|
sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
|
|
354
255
|
let team2 = nestedSide(r.team2, game) ??
|
|
355
256
|
sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
|
|
356
|
-
// CS2 rows nest team1/team2 objects that lack a score; a nested side must not
|
|
357
|
-
// shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
|
|
358
257
|
if (team1 && team1.score == null) {
|
|
359
258
|
const s = scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore);
|
|
360
259
|
if (s != null)
|
|
@@ -365,17 +264,11 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
365
264
|
if (s != null)
|
|
366
265
|
team2 = { ...team2, score: s };
|
|
367
266
|
}
|
|
368
|
-
// Tennis: the live board nests player1/player2 objects ({id,name,sets_won});
|
|
369
|
-
// raw live rows carry player1_name/player2_name; archive rows only carry
|
|
370
|
-
// winner_id/loser_id (ids, no names). Map all three onto sides so labels are
|
|
371
|
-
// never "? vs ?" and live scores surface as sets won.
|
|
372
267
|
if (game === 'tennis' && !team1 && !team2) {
|
|
373
268
|
const p1obj = asRecord(r.player1);
|
|
374
269
|
const p2obj = asRecord(r.player2);
|
|
375
270
|
const p1 = pickString(p1obj?.name, r.player1_name);
|
|
376
271
|
const p2 = pickString(p2obj?.name, r.player2_name);
|
|
377
|
-
// winner_sets_won/loser_sets_won and score/sets are match-level; only the
|
|
378
|
-
// side flags differ, so they are shared by both branches below.
|
|
379
272
|
const scoreText = pickString(r.score, r.score_raw) ?? null;
|
|
380
273
|
if (p1 || p2) {
|
|
381
274
|
const p1Id = pickString(p1obj?.id, r.player1_id) ?? null;
|
|
@@ -408,8 +301,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
408
301
|
team2 = sideFrom(p2, p2Id ?? undefined, undefined, score2, sideExtras(p2obj ?? {}, game));
|
|
409
302
|
}
|
|
410
303
|
else {
|
|
411
|
-
// Archive detail nests winner/loser objects with real names; list rows
|
|
412
|
-
// may only carry winner_id/loser_id (ids double as last-resort labels).
|
|
413
304
|
const wObj = asRecord(r.winner);
|
|
414
305
|
const lObj = asRecord(r.loser);
|
|
415
306
|
const wName = pickString(wObj?.name, r.winner_name);
|
|
@@ -417,7 +308,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
417
308
|
const wId = pickString(wObj?.id, r.winner_id);
|
|
418
309
|
const lId = pickString(lObj?.id, r.loser_id);
|
|
419
310
|
if (wName || lName || wId || lId) {
|
|
420
|
-
// This shape IS winner-first, so side 1 is the winner by construction.
|
|
421
311
|
const wScore = tennisSetsWon({
|
|
422
312
|
winnerSetsWon: r.winner_sets_won,
|
|
423
313
|
loserSetsWon: r.loser_sets_won,
|
|
@@ -439,9 +329,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
439
329
|
}
|
|
440
330
|
}
|
|
441
331
|
}
|
|
442
|
-
// UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
|
|
443
|
-
// Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
|
|
444
|
-
// Live tracking uses red/blue + fighters[] (not team1/team2).
|
|
445
332
|
if (game === 'ufc' || (!team1 && !team2 && (r.red || r.blue || Array.isArray(r.fighters)))) {
|
|
446
333
|
const fromCornerObj = (raw) => {
|
|
447
334
|
const o = asRecord(raw);
|
|
@@ -450,14 +337,9 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
450
337
|
return { name: raw.trim() };
|
|
451
338
|
return null;
|
|
452
339
|
}
|
|
453
|
-
// Nested profile from serializeBoutFighter
|
|
454
340
|
const profile = asRecord(o.profile);
|
|
455
341
|
const fighter = asRecord(o.fighter);
|
|
456
342
|
const name = pickString(o.name, o.fighterName, o.displayName, o.nickname, profile?.name, profile?.nickname, fighter?.name, fighter?.nickname);
|
|
457
|
-
// o.id on a serialized corner is the ufc_bout_fighters ROW id, unique per
|
|
458
|
-
// bout, so the same human got a different "id" on every card. Prefer the
|
|
459
|
-
// fighter's own id wherever the API supplies it; fall back to the row id
|
|
460
|
-
// only when nothing better exists.
|
|
461
343
|
const id = pickString(o.fighterId, profile?.id, fighter?.id, o.id);
|
|
462
344
|
const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
|
|
463
345
|
const score = scoreNum(o.score ?? o.points);
|
|
@@ -479,7 +361,7 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
479
361
|
if (fightersList.length) {
|
|
480
362
|
const byCorner = (want) => fightersList.find((f) => {
|
|
481
363
|
const c = pickString(asRecord(f)?.corner, asRecord(f)?.side)?.toLowerCase();
|
|
482
|
-
return c === want || c === want[0];
|
|
364
|
+
return c === want || c === want[0];
|
|
483
365
|
});
|
|
484
366
|
const redF = byCorner('red') ?? byCorner('r') ?? fightersList[0];
|
|
485
367
|
const blueF = byCorner('blue') ??
|
|
@@ -489,13 +371,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
489
371
|
team2 = team2 ?? fromCornerObj(blueF);
|
|
490
372
|
}
|
|
491
373
|
}
|
|
492
|
-
// UFC fighter-history rows (/ufc/fighters/{slug}/fights). These carry the
|
|
493
|
-
// fighter as fighterSlug/fighterName, the other corner under opponent, and
|
|
494
|
-
// the result as outcome, with NO fighters[] and no team1/team2. Every reader
|
|
495
|
-
// was left with two null sides: head_to_head filtered on those nulls and
|
|
496
|
-
// reported Jones vs Cormier as never having met, the profile showed
|
|
497
|
-
// team1:null with the division as the label, and the preview form could not
|
|
498
|
-
// say who won. One shape, three symptoms.
|
|
499
374
|
if (game === 'ufc' && !team1 && !team2 && typeof r.fighterSlug === 'string') {
|
|
500
375
|
const opp = asRecord(r.opponent);
|
|
501
376
|
const selfCorner = pickString(r.corner)?.toLowerCase();
|
|
@@ -503,8 +378,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
503
378
|
const other = opp
|
|
504
379
|
? sideFrom(pickString(opp.name) ?? pickString(opp.slug), undefined, pickString(opp.slug), undefined, sideExtras(opp, game))
|
|
505
380
|
: null;
|
|
506
|
-
// Keep red on team1 when we know the corner, so a card and a history row
|
|
507
|
-
// agree on which side is which.
|
|
508
381
|
if (selfCorner === 'blue' && other) {
|
|
509
382
|
team1 = other;
|
|
510
383
|
team2 = self;
|
|
@@ -514,19 +387,11 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
514
387
|
team2 = other;
|
|
515
388
|
}
|
|
516
389
|
}
|
|
517
|
-
// COD sometimes uses teams[]
|
|
518
390
|
if ((!team1 || !team2) && Array.isArray(r.teams)) {
|
|
519
391
|
team1 = team1 ?? nestedSide(r.teams[0], game);
|
|
520
392
|
team2 = team2 ?? nestedSide(r.teams[1], game);
|
|
521
393
|
}
|
|
522
|
-
const startTime = pickString(r.startTime, r.scheduledAt,
|
|
523
|
-
// Tennis emits scheduled_at; startTime was the API's only camelCase key
|
|
524
|
-
// and was removed. Without this the upcoming board loses its clock.
|
|
525
|
-
r.scheduled_at,
|
|
526
|
-
// A live row reports when it went in-play, not when it was scheduled.
|
|
527
|
-
r.started_at, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
|
|
528
|
-
// Tennis archive rows carry the state as outcome (COMPLETED / RETIREMENT /
|
|
529
|
-
// WALKOVER); read it, or every finished match reported status unknown.
|
|
394
|
+
const startTime = pickString(r.startTime, r.scheduledAt, r.scheduled_at, r.started_at, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
|
|
530
395
|
const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus, r.outcome)?.toLowerCase() ?? '';
|
|
531
396
|
let status = forcedStatus ?? 'unknown';
|
|
532
397
|
if (!forcedStatus) {
|
|
@@ -534,16 +399,11 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
534
399
|
r.resultRound != null ||
|
|
535
400
|
r.isComplete === true ||
|
|
536
401
|
r.completed === true;
|
|
537
|
-
// "started" as a bare substring also matches "not_started", which is how a
|
|
538
|
-
// fixture that has not begun was being served to customers as live. Require
|
|
539
|
-
// word boundaries, refuse the explicit not-started spellings, and refuse any
|
|
540
|
-
// match whose kickoff is still in the future: a scheduled match cannot be
|
|
541
|
-
// in progress no matter what the upstream status string says.
|
|
542
402
|
const unstarted = /not[_\s-]?started|unstarted|not[_\s-]?begun|yet[_\s-]?to[_\s-]?start/.test(statusRaw);
|
|
543
403
|
const looksLive = !unstarted && /\b(live|running|in[_\s-]?progress|ongoing|started|watching)\b/.test(statusRaw);
|
|
544
404
|
const kickoffPassed = (() => {
|
|
545
405
|
if (!startTime)
|
|
546
|
-
return true;
|
|
406
|
+
return true;
|
|
547
407
|
const t = Date.parse(startTime);
|
|
548
408
|
return !Number.isFinite(t) || t <= Date.now();
|
|
549
409
|
})();
|
|
@@ -556,7 +416,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
556
416
|
}
|
|
557
417
|
else if (/upcoming|scheduled|not_?started|unstarted|pending|soon|booked|confirmed|announced|tbd/.test(statusRaw) ||
|
|
558
418
|
(game === 'ufc' && !hasResult && (startTime || statusRaw === '' || statusRaw === 'unknown'))) {
|
|
559
|
-
// UFC bouts often omit status or use sparse values; default upcoming when no result yet.
|
|
560
419
|
if (hasResult)
|
|
561
420
|
status = 'completed';
|
|
562
421
|
else if (startTime) {
|
|
@@ -570,20 +429,12 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
570
429
|
else if (hasResult)
|
|
571
430
|
status = 'completed';
|
|
572
431
|
}
|
|
573
|
-
// The winner, resolved once. Explicit winner fields first; otherwise a
|
|
574
|
-
// history row states the result from the fighter's own side (outcome: win /
|
|
575
|
-
// loss), which translates back into a slug. Both the score below and the
|
|
576
|
-
// result block use this, so a fight the profile shows as a win also counts
|
|
577
|
-
// as a win in head_to_head's tally, which reads the scores.
|
|
578
432
|
const historyOutcome = pickString(r.outcome)?.toLowerCase();
|
|
579
433
|
const historyOpponent = pickString(asRecord(r.opponent)?.slug);
|
|
580
|
-
const resolvedWinnerSlug =
|
|
581
|
-
// winner_id is the tennis archive's spelling; winner there is an object.
|
|
582
|
-
pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id) ??
|
|
434
|
+
const resolvedWinnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id) ??
|
|
583
435
|
(historyOutcome === 'win' && typeof r.fighterSlug === 'string' ? r.fighterSlug
|
|
584
436
|
: historyOutcome === 'loss' && historyOpponent ? historyOpponent
|
|
585
437
|
: undefined);
|
|
586
|
-
// Winner → score 1-0 for UFC card display when numeric scores absent
|
|
587
438
|
if (game === 'ufc' && team1 && team2 && team1.score == null && team2.score == null) {
|
|
588
439
|
const winner = resolvedWinnerSlug?.toLowerCase();
|
|
589
440
|
if (winner) {
|
|
@@ -606,26 +457,13 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
606
457
|
const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
|
|
607
458
|
const leagueId = pickString(asRecord(r.league)?.id, r.leagueId);
|
|
608
459
|
const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
|
|
609
|
-
// Prefer explicit title/label when present, but never keep the placeholder
|
|
610
|
-
// "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
|
|
611
|
-
// When both sides lack names, prefer event / weight / bout id over opaque "? vs ?"
|
|
612
|
-
// so agent boards (live_matches, upcoming_schedule, event_card) stay legible.
|
|
613
460
|
const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
|
|
614
461
|
const explicitRaw = pickString(r.label, r.title, r.name);
|
|
615
|
-
// UFC history rows put the division where a title would go ("Heavyweight"),
|
|
616
|
-
// which then displaced "Jon Jones vs Stipe Miocic" as the label. A label that
|
|
617
|
-
// merely repeats the weight class is not a matchup; use the sides instead.
|
|
618
462
|
const explicit = game === 'ufc' && explicitRaw && (team1?.name || team2?.name) &&
|
|
619
463
|
explicitRaw.toLowerCase() === (pickString(r.weightClass, r.division) ?? '').toLowerCase()
|
|
620
464
|
? undefined
|
|
621
465
|
: explicitRaw;
|
|
622
466
|
const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
|
|
623
|
-
// Tennis rows carry no `label`, but the live route does set `name` — and that
|
|
624
|
-
// name is the TOURNAMENT ("Challenger, Seville"), not the matchup. Preferring
|
|
625
|
-
// it made live_matches label a tennis row by its event while
|
|
626
|
-
// upcoming_schedule, off a route with no such field, labelled the same
|
|
627
|
-
// fixture "Max Alcala Gurri vs Dusan Lajovic". Two boards, one match, two
|
|
628
|
-
// labels. A "matchup" that is really an event name is not a matchup.
|
|
629
467
|
const explicitIsEventName = explicit != null &&
|
|
630
468
|
game === 'tennis' &&
|
|
631
469
|
Boolean(team1?.name || team2?.name) &&
|
|
@@ -641,14 +479,11 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
641
479
|
label = vsLabel;
|
|
642
480
|
}
|
|
643
481
|
else {
|
|
644
|
-
// Identity fallbacks — never invent fighter names, but avoid bare "? vs ?"
|
|
645
482
|
label =
|
|
646
483
|
pickString(eventName, eventSlug, weightOrClass) ??
|
|
647
484
|
(matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
|
|
648
485
|
vsLabel;
|
|
649
486
|
}
|
|
650
|
-
// Bout metadata already present on the upstream row. Passing it through here
|
|
651
|
-
// is what stops an agent from N+1'ing call_api to rebuild a fight card.
|
|
652
487
|
const cardSection = pickString(r.cardSection, r.cardSegment, r.segment);
|
|
653
488
|
const cardPosition = pickString(r.cardPosition);
|
|
654
489
|
const cardSectionOrder = numOrNull(r.cardSectionOrder);
|
|
@@ -658,16 +493,11 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
658
493
|
const method = normalizeUfcMethod(methodRaw);
|
|
659
494
|
const methodDetails = pickString(r.methodDetails);
|
|
660
495
|
const resultTime = pickString(r.resultTime);
|
|
661
|
-
// UFC sends referee as { id, name, firstName, lastName } — not a string.
|
|
662
496
|
const referee = pickString(r.referee, asRecord(r.referee)?.name);
|
|
663
497
|
const winnerSlug = resolvedWinnerSlug;
|
|
664
498
|
const resultRound = numOrNull(r.resultRound);
|
|
665
499
|
const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
|
|
666
500
|
const isCancelled = r.isCancelled === true;
|
|
667
|
-
// Tennis live/detail rows carry a top-level sets[] (set_num/player*_games/
|
|
668
|
-
// tiebreak/is_completed) plus game_score/server/current_set. A live board
|
|
669
|
-
// that only reports "score: 1" cannot tell 6-0 6-0 from a 7-6 in progress;
|
|
670
|
-
// carry the real breakdown through instead of collapsing it to an integer.
|
|
671
501
|
let tennisSets;
|
|
672
502
|
let tennisGameScore = null;
|
|
673
503
|
let tennisServer = null;
|
|
@@ -678,15 +508,11 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
678
508
|
const p1g = numOrNull(sr.player1_games);
|
|
679
509
|
const p2g = numOrNull(sr.player2_games);
|
|
680
510
|
return {
|
|
681
|
-
// The live route spells it set_num; the archive route set_number. Both
|
|
682
|
-
// are emitted by the same API for the same set.
|
|
683
511
|
setNumber: numOrNull(sr.set_num) ?? numOrNull(sr.set_number),
|
|
684
512
|
score: pickString(sr.score) ?? (p1g !== null && p2g !== null ? `${p1g}-${p2g}` : null),
|
|
685
513
|
player1Games: p1g,
|
|
686
514
|
player2Games: p2g,
|
|
687
515
|
tiebreak: sr.tiebreak == null ? null : (pickString(sr.tiebreak) ?? String(sr.tiebreak)),
|
|
688
|
-
// is_completed is a live-route field only; the archive route omits it
|
|
689
|
-
// and every set of a finished match read as completed:false.
|
|
690
516
|
completed: tennisSetCompleted(sr),
|
|
691
517
|
};
|
|
692
518
|
});
|
|
@@ -704,8 +530,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
704
530
|
team2,
|
|
705
531
|
event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
|
|
706
532
|
league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
|
|
707
|
-
// weightClass is UFC vocabulary; other games never set it, but never lie
|
|
708
|
-
// it into existence from a tennis/LoL row that merely shares a field name.
|
|
709
533
|
...(weightOrClass && game === 'ufc' ? { weightClass: weightOrClass } : {}),
|
|
710
534
|
...(r.titleBout != null ? { titleBout: Boolean(r.titleBout) } : {}),
|
|
711
535
|
...(hasCard
|
|
@@ -721,10 +545,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
721
545
|
...(hasResultDetail
|
|
722
546
|
? {
|
|
723
547
|
result: {
|
|
724
|
-
// method/referee are combat-sport vocabulary with no tennis
|
|
725
|
-
// meaning; a tennis history row otherwise picks up a
|
|
726
|
-
// result.method:null / result.referee:null pair from this same
|
|
727
|
-
// object shape, which reads as "render a fight" to an agent.
|
|
728
548
|
...(game === 'ufc' ? { method: method ?? null, referee: referee ?? null } : {}),
|
|
729
549
|
methodRaw: methodRaw ?? null,
|
|
730
550
|
methodDetails: methodDetails ?? null,
|
|
@@ -742,11 +562,6 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
742
562
|
: {}),
|
|
743
563
|
};
|
|
744
564
|
}
|
|
745
|
-
/**
|
|
746
|
-
* Sort a fight card the way it is presented: main card before prelims, and the
|
|
747
|
-
* main event at the top of its section. Rows without placement keep their
|
|
748
|
-
* upstream order behind those that have it.
|
|
749
|
-
*/
|
|
750
565
|
export function sortByCardOrder(rows) {
|
|
751
566
|
const rank = (x, i) => ({
|
|
752
567
|
section: x.card?.sectionOrder ?? Number.MAX_SAFE_INTEGER,
|
|
@@ -768,7 +583,6 @@ export function entityRef(row, type, game) {
|
|
|
768
583
|
pickString(r.slug) ??
|
|
769
584
|
'unknown';
|
|
770
585
|
const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
|
|
771
|
-
// Events often use `title` not `name` (UFC)
|
|
772
586
|
const name = pickString(r.name, r.full_name, r.player_name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
|
|
773
587
|
const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
|
|
774
588
|
return {
|
|
@@ -789,11 +603,6 @@ export function entityRef(row, type, game) {
|
|
|
789
603
|
},
|
|
790
604
|
};
|
|
791
605
|
}
|
|
792
|
-
/**
|
|
793
|
-
* Fuzzy score for entity resolution: exact > startsWith > includes > token overlap.
|
|
794
|
-
* Extra args (nickname, alt names) are scored as additional candidates.
|
|
795
|
-
* Multi-token person names ("jon jones") get a strong boost when every token hits.
|
|
796
|
-
*/
|
|
797
606
|
export function rankScore(query, name, id, slug, ...extra) {
|
|
798
607
|
const q = query.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
799
608
|
if (!q)
|
|
@@ -816,27 +625,18 @@ export function rankScore(query, name, id, slug, ...extra) {
|
|
|
816
625
|
best = Math.max(best, 55 - Math.min(20, c.indexOf(q)));
|
|
817
626
|
else {
|
|
818
627
|
const ct = c.split(/[\s]+/).filter(Boolean);
|
|
819
|
-
// all query tokens present as whole tokens (order-independent)
|
|
820
628
|
const allTokens = qt.length > 0 &&
|
|
821
629
|
qt.every((t) => ct.some((x) => x === t || x.startsWith(t) || t.startsWith(x)));
|
|
822
630
|
if (allTokens && qt.length >= 2) {
|
|
823
|
-
best = Math.max(best, 92);
|
|
631
|
+
best = Math.max(best, 92);
|
|
824
632
|
}
|
|
825
633
|
else {
|
|
826
|
-
// A hit is the candidate token containing the query token, or the query
|
|
827
|
-
// token containing a candidate token OF REAL LENGTH. The reverse
|
|
828
|
-
// direction used to accept any length, so the single letter "e" in
|
|
829
|
-
// "E Skinner" counted as a hit against the query "sinner" ("sinner"
|
|
830
|
-
// contains "e") and scored 32 — which is how eight strangers padded a
|
|
831
|
-
// search for one player. A one- or two-character fragment carries no
|
|
832
|
-
// identity, so it no longer counts.
|
|
833
634
|
const hits = qt.filter((t) => ct.some((x) => x.includes(t) || (t.includes(x) && x.length >= 3))).length;
|
|
834
635
|
if (hits === qt.length && qt.length > 0)
|
|
835
636
|
best = Math.max(best, 75);
|
|
836
637
|
else if (hits)
|
|
837
638
|
best = Math.max(best, 20 + hits * 12);
|
|
838
639
|
}
|
|
839
|
-
// last-name only: query last token equals candidate last token
|
|
840
640
|
if (qt.length >= 2 && ct.length >= 1 && ct[ct.length - 1] === qt[qt.length - 1]) {
|
|
841
641
|
best = Math.max(best, 60);
|
|
842
642
|
}
|
|
@@ -844,13 +644,6 @@ export function rankScore(query, name, id, slug, ...extra) {
|
|
|
844
644
|
}
|
|
845
645
|
return best;
|
|
846
646
|
}
|
|
847
|
-
/**
|
|
848
|
-
* Edition year encoded in a tennis-style entity id ("atp_2026_580" -> 2026).
|
|
849
|
-
*
|
|
850
|
-
* `/tennis/competitions` returns no `year` field while the calendar does, so
|
|
851
|
-
* the id is the only signal available on both, and the two tools that disagreed
|
|
852
|
-
* about "Australian Open" were each reading a different one of those endpoints.
|
|
853
|
-
*/
|
|
854
647
|
export function editionYear(id, explicit) {
|
|
855
648
|
const direct = Number(explicit);
|
|
856
649
|
if (Number.isFinite(direct) && direct > 1800)
|
|
@@ -858,22 +651,6 @@ export function editionYear(id, explicit) {
|
|
|
858
651
|
const match = /^[a-z]+_(\d{4})_/i.exec(String(id ?? ''));
|
|
859
652
|
return match ? Number(match[1]) : null;
|
|
860
653
|
}
|
|
861
|
-
/**
|
|
862
|
-
* Deterministic winner among rows whose names match a query equally well.
|
|
863
|
-
*
|
|
864
|
-
* "Australian Open" is two rows in every season, one ATP and one WTA, sharing a
|
|
865
|
-
* name exactly. resolve_entity read /tennis/competitions, which lists ATP
|
|
866
|
-
* first; event_card read /tennis/tournaments/calendar, which lists WTA first;
|
|
867
|
-
* and neither applied a tiebreak, so the same question got atp_2026_580 from
|
|
868
|
-
* one tool and wta_2026_580 from the other. Neither answer was wrong on its
|
|
869
|
-
* own. The absence of a rule was the bug.
|
|
870
|
-
*
|
|
871
|
-
* Order: an exact name beats a substring, a newer edition beats an older one,
|
|
872
|
-
* and the id settles whatever is left. That last step is arbitrary, and that is
|
|
873
|
-
* fine -- it only has to be FIXED, which is the property that was missing.
|
|
874
|
-
* Callers that need the alternatives still surface them; this only decides
|
|
875
|
-
* which single row is called "best".
|
|
876
|
-
*/
|
|
877
654
|
export function chooseNamedEntity(rows, needle, read) {
|
|
878
655
|
const want = needle.trim().toLowerCase();
|
|
879
656
|
if (!want || !rows.length)
|