cito-mcp 0.3.21 → 0.4.1
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 +42 -2
- package/dist/tools/index.js +8 -0
- package/dist/tools/insight.js +95 -4
- package/dist/tools/match.js +75 -5
- package/dist/tools/meta.js +62 -2
- package/dist/tools/normalize.js +221 -15
- package/dist/tools/odds.js +295 -0
- package/dist/tools/player.js +423 -3
- package/dist/tools/rankings.js +214 -3
- package/dist/tools/resolve.js +67 -7
- package/dist/tools/schedule.js +323 -0
- package/dist/tools/standings.js +18 -2
- package/dist/tools/tournaments.js +319 -0
- package/package.json +2 -2
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)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tennis_odds — the betting-odds surface for tennis (TENNIS-24).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* The tennis API serves three odds routes, and NONE of them had a tool. Odds are
|
|
6
|
+
* a headline feature of the paid tiers and one of the most common reasons to buy
|
|
7
|
+
* a sports API, so a builder had to discover /tennis/odds/* through call_api or
|
|
8
|
+
* the docs. This closes that gap.
|
|
9
|
+
*
|
|
10
|
+
* Coverage is genuinely partial upstream, and the tool says so rather than
|
|
11
|
+
* returning an empty success: /tennis/odds/{id} answers with
|
|
12
|
+
* `coverage: { odds: false }` and an empty bookmakers[] for any match the feed
|
|
13
|
+
* has no prices for (verified against atp_2026_560, which returned exactly
|
|
14
|
+
* that). An agent that cannot tell "no odds exist for this match" from "the call
|
|
15
|
+
* failed" will invent a price, so the response distinguishes the two.
|
|
16
|
+
*/
|
|
17
|
+
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
18
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
19
|
+
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
20
|
+
/** A bookmaker row: { key, title, last_update, markets: [{ key, outcomes: [{ name, price }] }] }. */
|
|
21
|
+
function normalizeBookmaker(row) {
|
|
22
|
+
const r = asRecord(row) ?? {};
|
|
23
|
+
const markets = (Array.isArray(r.markets) ? r.markets : []).map((mk) => {
|
|
24
|
+
const m = asRecord(mk) ?? {};
|
|
25
|
+
const outcomes = (Array.isArray(m.outcomes) ? m.outcomes : []).map((o) => {
|
|
26
|
+
const oc = asRecord(o) ?? {};
|
|
27
|
+
return {
|
|
28
|
+
name: pickString(oc.name) ?? null,
|
|
29
|
+
// Decimal odds as the feed supplies them; never recomputed or implied.
|
|
30
|
+
price: typeof oc.price === 'number' && Number.isFinite(oc.price) ? oc.price : null,
|
|
31
|
+
point: typeof oc.point === 'number' && Number.isFinite(oc.point) ? oc.point : null,
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
market: pickString(m.key) ?? null,
|
|
36
|
+
outcomes,
|
|
37
|
+
// Implied probability from the prices actually returned, so a caller does
|
|
38
|
+
// not have to redo the arithmetic. Null when any price is missing.
|
|
39
|
+
impliedProbabilities: (() => {
|
|
40
|
+
const prices = outcomes.map((o) => o.price).filter((p) => p !== null && p > 1);
|
|
41
|
+
if (prices.length !== outcomes.length || prices.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
const inv = prices.map((p) => 1 / p);
|
|
44
|
+
const sum = inv.reduce((a, b) => a + b, 0);
|
|
45
|
+
return inv.map((v) => Number((v / sum).toFixed(4)));
|
|
46
|
+
})(),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
key: pickString(r.key) ?? null,
|
|
51
|
+
title: pickString(r.title) ?? null,
|
|
52
|
+
lastUpdate: pickString(r.last_update) ?? null,
|
|
53
|
+
markets,
|
|
54
|
+
};
|
|
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
|
+
}
|
|
89
|
+
function parseTennis(args, tool) {
|
|
90
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
91
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
92
|
+
return { error: gameParse.error ?? 'game is required', game: null };
|
|
93
|
+
}
|
|
94
|
+
const game = gameParse.game;
|
|
95
|
+
if (game !== 'tennis') {
|
|
96
|
+
return {
|
|
97
|
+
error: `${tool} is tennis-only (odds for '${game}' is not served by this endpoint)`,
|
|
98
|
+
game,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { game };
|
|
102
|
+
}
|
|
103
|
+
function requireTennisId(args, tool) {
|
|
104
|
+
const id = typeof args.matchId === 'string' ? args.matchId.trim() : '';
|
|
105
|
+
if (!id)
|
|
106
|
+
return { error: `${tool} requires matchId` };
|
|
107
|
+
return { matchId: id };
|
|
108
|
+
}
|
|
109
|
+
export const tennisOdds = {
|
|
110
|
+
name: 'tennis_odds',
|
|
111
|
+
description: `Tennis betting odds: upcoming matches with prices, plus pre-match and in-play odds for one match.
|
|
112
|
+
|
|
113
|
+
Modes (pick with 'scope'):
|
|
114
|
+
- upcoming (default): matches with odds available, each with the bookmakers quoting it.
|
|
115
|
+
- match: pre-match odds for one match_id.
|
|
116
|
+
- live: in-play odds for one match_id.
|
|
117
|
+
|
|
118
|
+
When to use:
|
|
119
|
+
- "What are the odds on X vs Y?"; pre-match prices; in-play prices; which matches have odds today.
|
|
120
|
+
|
|
121
|
+
Prefer over: call_api for /tennis/odds/*; match_preview (no prices).
|
|
122
|
+
|
|
123
|
+
Do not use when: the match result → match_details; rankings → standings.
|
|
124
|
+
|
|
125
|
+
Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
|
|
126
|
+
priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
|
|
127
|
+
than invented numbers, and this tool surfaces that distinction explicitly.
|
|
128
|
+
|
|
129
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
required: ['game'],
|
|
134
|
+
properties: {
|
|
135
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
136
|
+
scope: stringSchema('upcoming (default) | match | live.', 'upcoming'),
|
|
137
|
+
matchId: stringSchema('Match id for scope=match or scope=live, e.g. "s365_2026_4853596".', 's365_2026_4853596'),
|
|
138
|
+
limit: limitSchema({ default: 20, max: 50, description: 'Rows for scope=upcoming (default 20, max 50).' }),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
handler: async (args, ctx) => {
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
const requestId = newRequestId();
|
|
144
|
+
const parsed = parseTennis(args, 'tennis_odds');
|
|
145
|
+
if (parsed.error) {
|
|
146
|
+
return errorEnvelope({
|
|
147
|
+
code: parsed.error.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
148
|
+
message: parsed.error,
|
|
149
|
+
game: parsed.game,
|
|
150
|
+
source: 'tennis_odds',
|
|
151
|
+
requestId,
|
|
152
|
+
tookMs: Date.now() - started,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const game = parsed.game;
|
|
156
|
+
const scopeRaw = typeof args.scope === 'string' ? args.scope.trim().toLowerCase() : 'upcoming';
|
|
157
|
+
if (!['upcoming', 'match', 'live'].includes(scopeRaw)) {
|
|
158
|
+
return errorEnvelope({
|
|
159
|
+
code: 'VALIDATION',
|
|
160
|
+
message: `scope must be upcoming, match, or live (got '${args.scope}')`,
|
|
161
|
+
game,
|
|
162
|
+
source: 'tennis_odds',
|
|
163
|
+
requestId,
|
|
164
|
+
tookMs: Date.now() - started,
|
|
165
|
+
recover: ['Use scope=upcoming for matches with odds', 'Use scope=match or scope=live with a matchId'],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const matchId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
|
|
169
|
+
if (scopeRaw !== 'upcoming' && !matchId) {
|
|
170
|
+
return errorEnvelope({
|
|
171
|
+
code: 'VALIDATION',
|
|
172
|
+
message: `matchId is required when scope=${scopeRaw}`,
|
|
173
|
+
game,
|
|
174
|
+
source: 'tennis_odds',
|
|
175
|
+
requestId,
|
|
176
|
+
tookMs: Date.now() - started,
|
|
177
|
+
recover: [`Pass matchId, or use scope=upcoming with no id`],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
181
|
+
if (scopeRaw === 'upcoming') {
|
|
182
|
+
const res = await fetchJson(ctx, '/tennis/odds/upcoming', { query: { limit } });
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
return errorEnvelope({
|
|
185
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
186
|
+
message: `Upcoming tennis odds failed (HTTP ${res.status})`,
|
|
187
|
+
game,
|
|
188
|
+
source: 'tennis_odds',
|
|
189
|
+
requestId,
|
|
190
|
+
tookMs: Date.now() - started,
|
|
191
|
+
upstreamCalls: 1,
|
|
192
|
+
httpStatus: res.status,
|
|
193
|
+
rateLimit: res.headers,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const root = asRecord(res.data) ?? {};
|
|
197
|
+
const body = asRecord(root.data) ?? root;
|
|
198
|
+
const items = (Array.isArray(body.items) ? body.items : []).map((row) => {
|
|
199
|
+
const r = asRecord(row) ?? {};
|
|
200
|
+
const home = asRecord(r.home) ?? {};
|
|
201
|
+
const away = asRecord(r.away) ?? {};
|
|
202
|
+
const homeId = pickString(home.player_id, home.id) ?? null;
|
|
203
|
+
const awayId = pickString(away.player_id, away.id) ?? null;
|
|
204
|
+
const matchId = pickString(r.match_id, r.id) ?? null;
|
|
205
|
+
const oddsEventId = pickString(r.odds_event_id) ?? null;
|
|
206
|
+
return {
|
|
207
|
+
matchId,
|
|
208
|
+
oddsEventId,
|
|
209
|
+
tournament: pickString(r.tournament) ?? null,
|
|
210
|
+
commenceTime: pickString(r.commence_time, r.starts_at) ?? null,
|
|
211
|
+
player1: { name: pickString(home.name) ?? null, id: homeId },
|
|
212
|
+
player2: { name: pickString(away.name) ?? null, id: awayId },
|
|
213
|
+
bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
|
|
214
|
+
live: r.live === true,
|
|
215
|
+
/**
|
|
216
|
+
* The feed does not join its odds events to matches: every row of
|
|
217
|
+
* /tennis/odds/upcoming carries match_id:null (verified on all 94
|
|
218
|
+
* rows), so a caller could not go odds -> match and the two halves of
|
|
219
|
+
* a betting UI could not be stitched together. matchId itself is left
|
|
220
|
+
* exactly as the feed states it — null stays null, because inventing
|
|
221
|
+
* one would be worse — and the join key that DOES resolve is named
|
|
222
|
+
* alongside it.
|
|
223
|
+
*/
|
|
224
|
+
joinKey: matchId
|
|
225
|
+
? { kind: 'matchId', matchId }
|
|
226
|
+
: oddsEventId
|
|
227
|
+
? { kind: 'oddsEventId', oddsEventId }
|
|
228
|
+
: null,
|
|
229
|
+
/** Player ids present on the row, which identify the fixture when matchId is null. */
|
|
230
|
+
playerIds: [homeId, awayId].filter((x) => Boolean(x)),
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
234
|
+
return successEnvelope({
|
|
235
|
+
pagination: {
|
|
236
|
+
limit,
|
|
237
|
+
offset: 0,
|
|
238
|
+
total,
|
|
239
|
+
hasMore: body.has_next === true,
|
|
240
|
+
nextCursor: null,
|
|
241
|
+
prevCursor: null,
|
|
242
|
+
},
|
|
243
|
+
source: 'tennis_odds',
|
|
244
|
+
game,
|
|
245
|
+
requestId,
|
|
246
|
+
tookMs: Date.now() - started,
|
|
247
|
+
upstreamCalls: 1,
|
|
248
|
+
rateLimit: res.headers,
|
|
249
|
+
data: {
|
|
250
|
+
title: `Tennis odds — upcoming (${items.length} of ${total})`,
|
|
251
|
+
scope: scopeRaw,
|
|
252
|
+
oddsFormat: 'decimal',
|
|
253
|
+
items,
|
|
254
|
+
total,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const path = scopeRaw === 'match'
|
|
259
|
+
? `/tennis/odds/${encodeURIComponent(matchId)}`
|
|
260
|
+
: `/tennis/odds/${encodeURIComponent(matchId)}/live`;
|
|
261
|
+
const res = await fetchJson(ctx, path, {});
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
return errorEnvelope({
|
|
264
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
265
|
+
message: `Tennis ${scopeRaw} odds failed for '${matchId}' (HTTP ${res.status})`,
|
|
266
|
+
game,
|
|
267
|
+
source: 'tennis_odds',
|
|
268
|
+
requestId,
|
|
269
|
+
tookMs: Date.now() - started,
|
|
270
|
+
upstreamCalls: 1,
|
|
271
|
+
httpStatus: res.status,
|
|
272
|
+
rateLimit: res.headers,
|
|
273
|
+
recover: ['Check the match id with live_matches or match_details'],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const summary = summarizeTennisOdds(res.data);
|
|
277
|
+
return successEnvelope({
|
|
278
|
+
source: 'tennis_odds',
|
|
279
|
+
game,
|
|
280
|
+
requestId,
|
|
281
|
+
tookMs: Date.now() - started,
|
|
282
|
+
upstreamCalls: 1,
|
|
283
|
+
rateLimit: res.headers,
|
|
284
|
+
data: {
|
|
285
|
+
title: `Tennis ${scopeRaw} odds — ${matchId}`,
|
|
286
|
+
scope: scopeRaw,
|
|
287
|
+
// One projection, the same object match_details sections:["odds"]
|
|
288
|
+
// returns. See summarizeTennisOdds.
|
|
289
|
+
...summary,
|
|
290
|
+
matchId: summary.matchId ?? matchId,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
export const oddsTools = [tennisOdds];
|