cito-mcp 0.4.0 → 0.4.2
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 +48 -1
- package/dist/tools/insight.js +126 -7
- package/dist/tools/leaderboard.js +18 -2
- package/dist/tools/match.js +75 -5
- package/dist/tools/meta.js +6 -6
- package/dist/tools/normalize.js +221 -15
- package/dist/tools/odds.js +122 -20
- package/dist/tools/player.js +88 -7
- package/dist/tools/rankings.js +75 -10
- package/dist/tools/resolve.js +121 -8
- package/dist/tools/schedule.js +101 -12
- package/dist/tools/standings.js +63 -10
- package/dist/tools/tournaments.js +70 -4
- package/package.json +1 -1
package/dist/tools/normalize.js
CHANGED
|
@@ -62,6 +62,138 @@ function numOrNull(v) {
|
|
|
62
62
|
return Number(v);
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
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
|
+
export function parseTennisSetToken(token) {
|
|
73
|
+
const m = /^\s*(\d{1,2})\s*-\s*(\d{1,2})/.exec(token);
|
|
74
|
+
if (!m)
|
|
75
|
+
return null;
|
|
76
|
+
return { p1: Number(m[1]), p2: Number(m[2]) };
|
|
77
|
+
}
|
|
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
|
+
export function tennisSetsWonFromScore(score, side) {
|
|
88
|
+
if (!score)
|
|
89
|
+
return null;
|
|
90
|
+
let won = 0;
|
|
91
|
+
let parsed = 0;
|
|
92
|
+
for (const token of score.split(/[\s,]+/)) {
|
|
93
|
+
if (!token)
|
|
94
|
+
continue;
|
|
95
|
+
const set = parseTennisSetToken(token);
|
|
96
|
+
if (!set)
|
|
97
|
+
continue;
|
|
98
|
+
parsed += 1;
|
|
99
|
+
const mine = side === 1 ? set.p1 : set.p2;
|
|
100
|
+
const theirs = side === 1 ? set.p2 : set.p1;
|
|
101
|
+
if (mine > theirs)
|
|
102
|
+
won += 1;
|
|
103
|
+
}
|
|
104
|
+
return parsed > 0 ? won : null;
|
|
105
|
+
}
|
|
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
|
+
export function tennisSetsWonFromSets(sets, side) {
|
|
116
|
+
if (!Array.isArray(sets) || sets.length === 0)
|
|
117
|
+
return null;
|
|
118
|
+
let won = 0;
|
|
119
|
+
let parsed = 0;
|
|
120
|
+
for (const raw of sets) {
|
|
121
|
+
const s = asRecord(raw);
|
|
122
|
+
if (!s)
|
|
123
|
+
continue;
|
|
124
|
+
const p1 = numOrNull(s.player1_games);
|
|
125
|
+
const p2 = numOrNull(s.player2_games);
|
|
126
|
+
if (p1 === null || p2 === null)
|
|
127
|
+
continue;
|
|
128
|
+
parsed += 1;
|
|
129
|
+
const mine = side === 1 ? p1 : p2;
|
|
130
|
+
const theirs = side === 1 ? p2 : p1;
|
|
131
|
+
if (mine > theirs)
|
|
132
|
+
won += 1;
|
|
133
|
+
}
|
|
134
|
+
return parsed > 0 ? won : null;
|
|
135
|
+
}
|
|
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
|
+
export function tennisSetCompleted(sr) {
|
|
145
|
+
if (typeof sr.is_completed === 'boolean')
|
|
146
|
+
return sr.is_completed;
|
|
147
|
+
const p1 = numOrNull(sr.player1_games);
|
|
148
|
+
const p2 = numOrNull(sr.player2_games);
|
|
149
|
+
if (p1 === null || p2 === null)
|
|
150
|
+
return false;
|
|
151
|
+
const hi = Math.max(p1, p2);
|
|
152
|
+
const lo = Math.min(p1, p2);
|
|
153
|
+
return (hi >= 6 && hi - lo >= 2) || (hi === 7 && lo === 6);
|
|
154
|
+
}
|
|
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
|
+
export function tennisSetsWon(opts) {
|
|
172
|
+
const explicit = numOrNull(opts.sideSetsWon);
|
|
173
|
+
if (explicit !== null)
|
|
174
|
+
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
|
+
const wsw = numOrNull(opts.winnerSetsWon);
|
|
180
|
+
const lsw = numOrNull(opts.loserSetsWon);
|
|
181
|
+
if (wsw !== null && lsw !== null) {
|
|
182
|
+
let isWinner = opts.isWinner;
|
|
183
|
+
if (isWinner === null || isWinner === undefined) {
|
|
184
|
+
if (opts.winnerId && opts.sideId)
|
|
185
|
+
isWinner = opts.winnerId === opts.sideId;
|
|
186
|
+
}
|
|
187
|
+
if (isWinner === true)
|
|
188
|
+
return wsw;
|
|
189
|
+
if (isWinner === false)
|
|
190
|
+
return lsw;
|
|
191
|
+
}
|
|
192
|
+
const fromSets = tennisSetsWonFromSets(Array.isArray(opts.sets) ? opts.sets : [], opts.side);
|
|
193
|
+
if (fromSets !== null)
|
|
194
|
+
return fromSets;
|
|
195
|
+
return tennisSetsWonFromScore(opts.score ?? undefined, opts.side);
|
|
196
|
+
}
|
|
65
197
|
/**
|
|
66
198
|
* Collect image URLs from a corner/team row and its nested profile. Returns
|
|
67
199
|
* undefined when the upstream carried none, so lean rows stay lean; when any
|
|
@@ -82,9 +214,11 @@ function imagesFrom(game, ...sources) {
|
|
|
82
214
|
return null;
|
|
83
215
|
};
|
|
84
216
|
const images = {
|
|
85
|
-
|
|
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
|
+
headshotUrl: pick('headshotUrl', 'headshot', 'portrait_url'),
|
|
86
220
|
bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
|
|
87
|
-
imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo'),
|
|
221
|
+
imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo', 'portrait_url'),
|
|
88
222
|
proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
|
|
89
223
|
};
|
|
90
224
|
if (!images.proxiedImageUrl) {
|
|
@@ -125,7 +259,9 @@ function sideExtras(o, game) {
|
|
|
125
259
|
const rank = pickString(o.rankText, o.rank, profile?.rankText);
|
|
126
260
|
const championStatus = pickString(o.championStatus, profile?.championStatus);
|
|
127
261
|
const division = pickString(profile?.division, o.division, o.weightClass);
|
|
128
|
-
|
|
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
|
+
const country = pickString(o.country, profile?.country, o.ioc);
|
|
129
265
|
const flag = pickString(o.flag, profile?.flag);
|
|
130
266
|
const outcome = pickString(o.outcome);
|
|
131
267
|
return {
|
|
@@ -238,11 +374,38 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
238
374
|
const p2obj = asRecord(r.player2);
|
|
239
375
|
const p1 = pickString(p1obj?.name, r.player1_name);
|
|
240
376
|
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
|
+
const scoreText = pickString(r.score, r.score_raw) ?? null;
|
|
241
380
|
if (p1 || p2) {
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
381
|
+
const p1Id = pickString(p1obj?.id, r.player1_id) ?? null;
|
|
382
|
+
const p2Id = pickString(p2obj?.id, r.player2_id) ?? null;
|
|
383
|
+
const winnerId = pickString(r.winner_id) ?? null;
|
|
384
|
+
const boolOrNull = (v) => (typeof v === 'boolean' ? v : null);
|
|
385
|
+
const score1 = tennisSetsWon({
|
|
386
|
+
sideSetsWon: p1obj?.sets_won,
|
|
387
|
+
winnerSetsWon: r.winner_sets_won,
|
|
388
|
+
loserSetsWon: r.loser_sets_won,
|
|
389
|
+
isWinner: boolOrNull(p1obj?.is_winner),
|
|
390
|
+
winnerId,
|
|
391
|
+
sideId: p1Id,
|
|
392
|
+
sets: r.sets,
|
|
393
|
+
score: scoreText,
|
|
394
|
+
side: 1,
|
|
395
|
+
});
|
|
396
|
+
const score2 = tennisSetsWon({
|
|
397
|
+
sideSetsWon: p2obj?.sets_won,
|
|
398
|
+
winnerSetsWon: r.winner_sets_won,
|
|
399
|
+
loserSetsWon: r.loser_sets_won,
|
|
400
|
+
isWinner: boolOrNull(p2obj?.is_winner),
|
|
401
|
+
winnerId,
|
|
402
|
+
sideId: p2Id,
|
|
403
|
+
sets: r.sets,
|
|
404
|
+
score: scoreText,
|
|
405
|
+
side: 2,
|
|
406
|
+
});
|
|
407
|
+
team1 = sideFrom(p1, p1Id ?? undefined, undefined, score1, sideExtras(p1obj ?? {}, game));
|
|
408
|
+
team2 = sideFrom(p2, p2Id ?? undefined, undefined, score2, sideExtras(p2obj ?? {}, game));
|
|
246
409
|
}
|
|
247
410
|
else {
|
|
248
411
|
// Archive detail nests winner/loser objects with real names; list rows
|
|
@@ -254,8 +417,25 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
254
417
|
const wId = pickString(wObj?.id, r.winner_id);
|
|
255
418
|
const lId = pickString(lObj?.id, r.loser_id);
|
|
256
419
|
if (wName || lName || wId || lId) {
|
|
257
|
-
|
|
258
|
-
|
|
420
|
+
// This shape IS winner-first, so side 1 is the winner by construction.
|
|
421
|
+
const wScore = tennisSetsWon({
|
|
422
|
+
winnerSetsWon: r.winner_sets_won,
|
|
423
|
+
loserSetsWon: r.loser_sets_won,
|
|
424
|
+
isWinner: true,
|
|
425
|
+
sets: r.sets,
|
|
426
|
+
score: scoreText,
|
|
427
|
+
side: 1,
|
|
428
|
+
});
|
|
429
|
+
const lScore = tennisSetsWon({
|
|
430
|
+
winnerSetsWon: r.winner_sets_won,
|
|
431
|
+
loserSetsWon: r.loser_sets_won,
|
|
432
|
+
isWinner: false,
|
|
433
|
+
sets: r.sets,
|
|
434
|
+
score: scoreText,
|
|
435
|
+
side: 2,
|
|
436
|
+
});
|
|
437
|
+
team1 = sideFrom(wName ?? wId, wId, undefined, wScore);
|
|
438
|
+
team2 = sideFrom(lName ?? lId, lId, undefined, lScore);
|
|
259
439
|
}
|
|
260
440
|
}
|
|
261
441
|
}
|
|
@@ -440,9 +620,21 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
440
620
|
? undefined
|
|
441
621
|
: explicitRaw;
|
|
442
622
|
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
|
+
const explicitIsEventName = explicit != null &&
|
|
630
|
+
game === 'tennis' &&
|
|
631
|
+
Boolean(team1?.name || team2?.name) &&
|
|
632
|
+
[eventName, eventSlug, r.tournament_name, r.tournament]
|
|
633
|
+
.filter((v) => typeof v === 'string' && v.length > 0)
|
|
634
|
+
.some((v) => v.trim().toLowerCase() === explicit.trim().toLowerCase());
|
|
443
635
|
const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
|
|
444
636
|
let label;
|
|
445
|
-
if (explicit && !explicitIsPlaceholder) {
|
|
637
|
+
if (explicit && !explicitIsPlaceholder && !explicitIsEventName) {
|
|
446
638
|
label = explicit;
|
|
447
639
|
}
|
|
448
640
|
else if (team1?.name || team2?.name) {
|
|
@@ -483,12 +675,19 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
483
675
|
if (game === 'tennis' && Array.isArray(r.sets) && r.sets.length) {
|
|
484
676
|
tennisSets = r.sets.map((s) => {
|
|
485
677
|
const sr = asRecord(s) ?? {};
|
|
678
|
+
const p1g = numOrNull(sr.player1_games);
|
|
679
|
+
const p2g = numOrNull(sr.player2_games);
|
|
486
680
|
return {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
+
setNumber: numOrNull(sr.set_num) ?? numOrNull(sr.set_number),
|
|
684
|
+
score: pickString(sr.score) ?? (p1g !== null && p2g !== null ? `${p1g}-${p2g}` : null),
|
|
685
|
+
player1Games: p1g,
|
|
686
|
+
player2Games: p2g,
|
|
490
687
|
tiebreak: sr.tiebreak == null ? null : (pickString(sr.tiebreak) ?? String(sr.tiebreak)),
|
|
491
|
-
|
|
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
|
+
completed: tennisSetCompleted(sr),
|
|
492
691
|
};
|
|
493
692
|
});
|
|
494
693
|
tennisGameScore = pickString(r.game_score) ?? null;
|
|
@@ -624,7 +823,14 @@ export function rankScore(query, name, id, slug, ...extra) {
|
|
|
624
823
|
best = Math.max(best, 92); // "jon jones" vs "Jon Jones"
|
|
625
824
|
}
|
|
626
825
|
else {
|
|
627
|
-
|
|
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
|
+
const hits = qt.filter((t) => ct.some((x) => x.includes(t) || (t.includes(x) && x.length >= 3))).length;
|
|
628
834
|
if (hits === qt.length && qt.length > 0)
|
|
629
835
|
best = Math.max(best, 75);
|
|
630
836
|
else if (hits)
|
package/dist/tools/odds.js
CHANGED
|
@@ -53,6 +53,39 @@ function normalizeBookmaker(row) {
|
|
|
53
53
|
markets,
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The one tennis-odds projection, shared by `tennis_odds` and `match_details`.
|
|
58
|
+
*
|
|
59
|
+
* Both surfaces read the same REST payload, and before this the two disagreed:
|
|
60
|
+
* `tennis_odds scope=match` served FanDuel 1.105 / Matchbook 1.13 with implied
|
|
61
|
+
* probabilities while `match_details sections:["odds"]` answered
|
|
62
|
+
* NOT_IMPLEMENTED "UFC only today" for the same match id. A caller could not
|
|
63
|
+
* tell whether tennis odds existed. One shaper, one answer.
|
|
64
|
+
*/
|
|
65
|
+
export function summarizeTennisOdds(data) {
|
|
66
|
+
const root = asRecord(data) ?? {};
|
|
67
|
+
// fetchJson hands back the whole REST envelope; the unit tests feed the inner
|
|
68
|
+
// object directly. Accept both.
|
|
69
|
+
const payload = asRecord(root.data) ?? root;
|
|
70
|
+
const coverage = asRecord(payload.coverage) ?? {};
|
|
71
|
+
const bookmakers = (Array.isArray(payload.bookmakers) ? payload.bookmakers : []).map(normalizeBookmaker);
|
|
72
|
+
const hasOdds = coverage.odds === true || bookmakers.length > 0;
|
|
73
|
+
return {
|
|
74
|
+
matchId: pickString(payload.match_id) ?? null,
|
|
75
|
+
oddsFormat: pickString(payload.odds_format) ?? 'decimal',
|
|
76
|
+
commenceTime: pickString(payload.commence_time) ?? null,
|
|
77
|
+
/** Upstream says the feed has since moved on; the prices are still real. */
|
|
78
|
+
stale: payload.stale === true,
|
|
79
|
+
bookmakers,
|
|
80
|
+
// Explicit, because an empty bookmakers[] is otherwise indistinguishable
|
|
81
|
+
// from a failed scrape.
|
|
82
|
+
oddsAvailable: hasOdds,
|
|
83
|
+
note: hasOdds
|
|
84
|
+
? null
|
|
85
|
+
: 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
|
|
86
|
+
fullBookPath: '/tennis/odds/{matchId}',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
56
89
|
function parseTennis(args, tool) {
|
|
57
90
|
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
58
91
|
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
@@ -92,6 +125,10 @@ Do not use when: the match result → match_details; rankings → standings.
|
|
|
92
125
|
Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
|
|
93
126
|
priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
|
|
94
127
|
than invented numbers, and this tool surfaces that distinction explicitly.
|
|
128
|
+
A match id that does not exist is a NOT_FOUND error, not a coverage gap: the
|
|
129
|
+
odds route answers 200/odds:false for every unknown id, so scope=match and
|
|
130
|
+
scope=live verify the match against /tennis/matches/{id} before reporting a gap,
|
|
131
|
+
and set matchExists.
|
|
95
132
|
|
|
96
133
|
Parallel-safe: yes. Upstream cost: 1.`,
|
|
97
134
|
inputSchema: {
|
|
@@ -166,15 +203,35 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
166
203
|
const r = asRecord(row) ?? {};
|
|
167
204
|
const home = asRecord(r.home) ?? {};
|
|
168
205
|
const away = asRecord(r.away) ?? {};
|
|
206
|
+
const homeId = pickString(home.player_id, home.id) ?? null;
|
|
207
|
+
const awayId = pickString(away.player_id, away.id) ?? null;
|
|
208
|
+
const matchId = pickString(r.match_id, r.id) ?? null;
|
|
209
|
+
const oddsEventId = pickString(r.odds_event_id) ?? null;
|
|
169
210
|
return {
|
|
170
|
-
matchId
|
|
171
|
-
oddsEventId
|
|
211
|
+
matchId,
|
|
212
|
+
oddsEventId,
|
|
172
213
|
tournament: pickString(r.tournament) ?? null,
|
|
173
214
|
commenceTime: pickString(r.commence_time, r.starts_at) ?? null,
|
|
174
|
-
player1: { name: pickString(home.name) ?? null, id:
|
|
175
|
-
player2: { name: pickString(away.name) ?? null, id:
|
|
215
|
+
player1: { name: pickString(home.name) ?? null, id: homeId },
|
|
216
|
+
player2: { name: pickString(away.name) ?? null, id: awayId },
|
|
176
217
|
bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
|
|
177
218
|
live: r.live === true,
|
|
219
|
+
/**
|
|
220
|
+
* The feed does not join its odds events to matches: every row of
|
|
221
|
+
* /tennis/odds/upcoming carries match_id:null (verified on all 94
|
|
222
|
+
* rows), so a caller could not go odds -> match and the two halves of
|
|
223
|
+
* a betting UI could not be stitched together. matchId itself is left
|
|
224
|
+
* exactly as the feed states it — null stays null, because inventing
|
|
225
|
+
* one would be worse — and the join key that DOES resolve is named
|
|
226
|
+
* alongside it.
|
|
227
|
+
*/
|
|
228
|
+
joinKey: matchId
|
|
229
|
+
? { kind: 'matchId', matchId }
|
|
230
|
+
: oddsEventId
|
|
231
|
+
? { kind: 'oddsEventId', oddsEventId }
|
|
232
|
+
: null,
|
|
233
|
+
/** Player ids present on the row, which identify the fixture when matchId is null. */
|
|
234
|
+
playerIds: [homeId, awayId].filter((x) => Boolean(x)),
|
|
178
235
|
};
|
|
179
236
|
});
|
|
180
237
|
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
@@ -206,6 +263,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
206
263
|
? `/tennis/odds/${encodeURIComponent(matchId)}`
|
|
207
264
|
: `/tennis/odds/${encodeURIComponent(matchId)}/live`;
|
|
208
265
|
const res = await fetchJson(ctx, path, {});
|
|
266
|
+
let upstreamCalls = 1;
|
|
267
|
+
let rateLimit = res.headers;
|
|
209
268
|
if (!res.ok) {
|
|
210
269
|
return errorEnvelope({
|
|
211
270
|
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
@@ -220,30 +279,73 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
220
279
|
recover: ['Check the match id with live_matches or match_details'],
|
|
221
280
|
});
|
|
222
281
|
}
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
282
|
+
const summary = summarizeTennisOdds(res.data);
|
|
283
|
+
/**
|
|
284
|
+
* A match id that does not exist and a real match nobody is quoting look
|
|
285
|
+
* identical on the wire.
|
|
286
|
+
*
|
|
287
|
+
* /tennis/odds/{id} answers HTTP 200 with `coverage: { odds: false }` for
|
|
288
|
+
* EVERY id it does not know — verified for `s365_2026_0000000` (a plausible
|
|
289
|
+
* typo) and for the literal string `total-garbage-id`, both of which came
|
|
290
|
+
* back 200/odds:false. So the status code cannot tell them apart, and this
|
|
291
|
+
* tool used to report a typo as "upstream coverage gap, not an error" with
|
|
292
|
+
* ok:true — leaving the caller to conclude a real match has no prices.
|
|
293
|
+
*
|
|
294
|
+
* /tennis/matches/{id} DOES 404 for those same ids (verified: 404 NOT_FOUND
|
|
295
|
+
* for both, 200 for a real one), so that is the discriminator. It costs one
|
|
296
|
+
* extra call, and only on the no-odds path, where the answer is otherwise
|
|
297
|
+
* useless. player_stats already gets this right (404 -> NOT_FOUND); this
|
|
298
|
+
* makes odds agree with it.
|
|
299
|
+
*/
|
|
300
|
+
let matchExists = null;
|
|
301
|
+
if (summary.oddsAvailable !== true) {
|
|
302
|
+
const probe = await fetchJson(ctx, `/tennis/matches/${encodeURIComponent(matchId)}`);
|
|
303
|
+
upstreamCalls += 1;
|
|
304
|
+
rateLimit = { ...rateLimit, ...probe.headers };
|
|
305
|
+
matchExists = probe.ok;
|
|
306
|
+
if (!matchExists) {
|
|
307
|
+
// Live ids live at /matches/live/{id} until the match archives.
|
|
308
|
+
const liveProbe = await fetchJson(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
|
|
309
|
+
upstreamCalls += 1;
|
|
310
|
+
rateLimit = { ...rateLimit, ...liveProbe.headers };
|
|
311
|
+
matchExists = liveProbe.ok;
|
|
312
|
+
}
|
|
313
|
+
if (!matchExists) {
|
|
314
|
+
return errorEnvelope({
|
|
315
|
+
code: 'NOT_FOUND',
|
|
316
|
+
message: `No tennis match with id '${matchId}' — the odds feed has no event for it because the id itself does not exist`,
|
|
317
|
+
game,
|
|
318
|
+
source: 'tennis_odds',
|
|
319
|
+
requestId,
|
|
320
|
+
tookMs: Date.now() - started,
|
|
321
|
+
upstreamCalls,
|
|
322
|
+
rateLimit,
|
|
323
|
+
httpStatus: 200,
|
|
324
|
+
recover: [
|
|
325
|
+
'Check the id with live_matches or tennis_schedule / match_summary',
|
|
326
|
+
'Ids come from live_matches (s365_*) or the archive (s365_{year}_{gid})',
|
|
327
|
+
`For the pre-match odds list instead, call tennis_odds { game: "tennis", scope: "upcoming" }`,
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
228
332
|
return successEnvelope({
|
|
229
333
|
source: 'tennis_odds',
|
|
230
334
|
game,
|
|
231
335
|
requestId,
|
|
232
336
|
tookMs: Date.now() - started,
|
|
233
|
-
upstreamCalls
|
|
234
|
-
rateLimit
|
|
337
|
+
upstreamCalls,
|
|
338
|
+
rateLimit,
|
|
235
339
|
data: {
|
|
236
340
|
title: `Tennis ${scopeRaw} odds — ${matchId}`,
|
|
237
341
|
scope: scopeRaw,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
? null
|
|
246
|
-
: 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
|
|
342
|
+
// One projection, the same object match_details sections:["odds"]
|
|
343
|
+
// returns. See summarizeTennisOdds.
|
|
344
|
+
...summary,
|
|
345
|
+
matchId: summary.matchId ?? matchId,
|
|
346
|
+
// true when the no-odds path verified the match exists; null when odds
|
|
347
|
+
// existed so no verification was needed.
|
|
348
|
+
matchExists,
|
|
247
349
|
},
|
|
248
350
|
});
|
|
249
351
|
},
|
package/dist/tools/player.js
CHANGED
|
@@ -53,9 +53,16 @@ function identityFrom(game, raw, idHint, slugHint) {
|
|
|
53
53
|
* own domain, so it works from a browser without hotlink/CORS trouble.
|
|
54
54
|
*/
|
|
55
55
|
images: {
|
|
56
|
-
|
|
56
|
+
// Tennis supplies exactly one likeness, `portrait_url` (a Wikimedia
|
|
57
|
+
// portrait), and no separate square headshot crop. Before this, the shaper
|
|
58
|
+
// read only headshotUrl/bodyImageUrl/imageUrl/photoUrl — none of which the
|
|
59
|
+
// tennis route emits — so every tennis profile shipped four explicit nulls
|
|
60
|
+
// while a perfectly good photo sat one key away, and builders concluded
|
|
61
|
+
// tennis had no images at all. The portrait fills the headshot slot too:
|
|
62
|
+
// a card with the real photo slightly cropped beats a faceless card.
|
|
63
|
+
headshotUrl: pickString(r.headshotUrl, r.headshot, r.portrait_url) ?? null,
|
|
57
64
|
bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
|
|
58
|
-
imageUrl: pickString(r.imageUrl, r.image, r.photoUrl) ?? null,
|
|
65
|
+
imageUrl: pickString(r.imageUrl, r.image, r.photoUrl, r.portrait_url) ?? null,
|
|
59
66
|
proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
|
|
60
67
|
},
|
|
61
68
|
};
|
|
@@ -1064,16 +1071,85 @@ Upstream cost: 1.`,
|
|
|
1064
1071
|
* Map an arbitrary {label: value} breakdown without assuming the upstream
|
|
1065
1072
|
* key set: the endpoint has grown keys before and a hardcoded list would
|
|
1066
1073
|
* silently drop new ones.
|
|
1074
|
+
*
|
|
1075
|
+
* Values that are OBJECTS are skipped rather than coerced to null. That
|
|
1076
|
+
* coercion is exactly what broke `bySurface`: the endpoint publishes
|
|
1077
|
+
* `surface_breakdown: { hard: { matches, won, lost, win_pct, titles }, ... }`
|
|
1078
|
+
* (one object per surface) and this ran each value through Number(), so
|
|
1079
|
+
* every tennis player's surface split came back
|
|
1080
|
+
* `{hard:null, clay:null, grass:null, carpet:null}` — permanently, on every
|
|
1081
|
+
* profile, while `player_profile` served the identical numbers from the
|
|
1082
|
+
* identical source. A key that is present and null reads as "this player
|
|
1083
|
+
* has no clay record", which is a worse lie than omitting it.
|
|
1084
|
+
*/
|
|
1085
|
+
const camel = (key) => key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
1086
|
+
const flatNumeric = (value) => {
|
|
1087
|
+
const rec = asRecord(value);
|
|
1088
|
+
if (!rec)
|
|
1089
|
+
return null;
|
|
1090
|
+
const out = {};
|
|
1091
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
1092
|
+
if (asRecord(v))
|
|
1093
|
+
continue;
|
|
1094
|
+
out[camel(k)] = num(v);
|
|
1095
|
+
}
|
|
1096
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
1097
|
+
};
|
|
1098
|
+
/**
|
|
1099
|
+
* One OBJECT per bucket (surface, level, …), with the bucket's own fields
|
|
1100
|
+
* renamed to stable camelCase keys. A bucket that arrives as a bare number
|
|
1101
|
+
* is preserved as { value }, so a shape change narrows the payload instead
|
|
1102
|
+
* of emptying it.
|
|
1067
1103
|
*/
|
|
1068
|
-
const
|
|
1104
|
+
const bucketBlock = (value, fields) => {
|
|
1069
1105
|
const rec = asRecord(value);
|
|
1070
1106
|
if (!rec)
|
|
1071
1107
|
return null;
|
|
1072
1108
|
const out = {};
|
|
1073
|
-
for (const [
|
|
1074
|
-
|
|
1109
|
+
for (const [bucket, raw] of Object.entries(rec)) {
|
|
1110
|
+
const inner = asRecord(raw);
|
|
1111
|
+
if (inner) {
|
|
1112
|
+
const mapped = {};
|
|
1113
|
+
for (const [outKey, srcKey] of fields) {
|
|
1114
|
+
if (srcKey in inner)
|
|
1115
|
+
mapped[outKey] = num(inner[srcKey]);
|
|
1116
|
+
}
|
|
1117
|
+
out[bucket] = Object.keys(mapped).length > 0 ? mapped : flatNumeric(inner) ?? {};
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
out[bucket] = { value: num(raw) };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1075
1123
|
return Object.keys(out).length > 0 ? out : null;
|
|
1076
1124
|
};
|
|
1125
|
+
const SURFACE_FIELDS = [
|
|
1126
|
+
['matches', 'matches'],
|
|
1127
|
+
['won', 'won'],
|
|
1128
|
+
['lost', 'lost'],
|
|
1129
|
+
['winPct', 'win_pct'],
|
|
1130
|
+
['titles', 'titles'],
|
|
1131
|
+
];
|
|
1132
|
+
const bySurface = bucketBlock(body.surface_breakdown ?? body.by_surface, SURFACE_FIELDS);
|
|
1133
|
+
const byLevel = bucketBlock(body.by_level ?? body.level_breakdown, SURFACE_FIELDS);
|
|
1134
|
+
const servingStats = flatNumeric(body.serving_stats);
|
|
1135
|
+
const returnStats = flatNumeric(body.return_stats);
|
|
1136
|
+
const clutchAndSituational = flatNumeric(body.clutch_and_situational);
|
|
1137
|
+
const grandSlamBreakdown = bucketBlock(body.grand_slam_breakdown, SURFACE_FIELDS);
|
|
1138
|
+
// Sections this endpoint declares but does not publish for this player.
|
|
1139
|
+
// Named explicitly so an absent key is not read as "no such data exists".
|
|
1140
|
+
const unavailable = {};
|
|
1141
|
+
if (!byLevel) {
|
|
1142
|
+
unavailable.byLevel =
|
|
1143
|
+
'This endpoint publishes no per-level breakdown. Pass level= to a player_stats call, or use tournaments/event_card for level-scoped results.';
|
|
1144
|
+
}
|
|
1145
|
+
if (!returnStats)
|
|
1146
|
+
unavailable.returnStats = 'Not published for this player yet.';
|
|
1147
|
+
if (!clutchAndSituational) {
|
|
1148
|
+
unavailable.clutchAndSituational = 'Not published for this player yet.';
|
|
1149
|
+
}
|
|
1150
|
+
if (!grandSlamBreakdown) {
|
|
1151
|
+
unavailable.grandSlamBreakdown = 'Not published for this player yet.';
|
|
1152
|
+
}
|
|
1077
1153
|
return successEnvelope({
|
|
1078
1154
|
source: 'player_stats',
|
|
1079
1155
|
game,
|
|
@@ -1102,8 +1178,13 @@ Upstream cost: 1.`,
|
|
|
1102
1178
|
mastersTitles: num(summary.masters_titles),
|
|
1103
1179
|
finalsReached: num(summary.finals_reached),
|
|
1104
1180
|
},
|
|
1105
|
-
bySurface
|
|
1106
|
-
byLevel
|
|
1181
|
+
bySurface,
|
|
1182
|
+
byLevel,
|
|
1183
|
+
servingStats,
|
|
1184
|
+
returnStats,
|
|
1185
|
+
clutchAndSituational,
|
|
1186
|
+
grandSlamBreakdown,
|
|
1187
|
+
...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
|
|
1107
1188
|
},
|
|
1108
1189
|
});
|
|
1109
1190
|
},
|