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/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
|
};
|
|
@@ -769,4 +776,417 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
769
776
|
});
|
|
770
777
|
},
|
|
771
778
|
};
|
|
772
|
-
export const
|
|
779
|
+
export const playerMatches = {
|
|
780
|
+
name: 'player_matches',
|
|
781
|
+
description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
|
|
782
|
+
|
|
783
|
+
When to use:
|
|
784
|
+
- "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
|
|
785
|
+
|
|
786
|
+
Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
|
|
787
|
+
|
|
788
|
+
Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
|
|
789
|
+
|
|
790
|
+
Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
|
|
791
|
+
surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
|
|
792
|
+
|
|
793
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
794
|
+
inputSchema: {
|
|
795
|
+
type: 'object',
|
|
796
|
+
additionalProperties: false,
|
|
797
|
+
required: ['game', 'playerId'],
|
|
798
|
+
properties: {
|
|
799
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
800
|
+
playerId: stringSchema('Tennis player id, e.g. "atp_210097".', 'atp_210097'),
|
|
801
|
+
surface: stringSchema('Filter to one surface: Hard, Clay, or Grass.', 'Hard'),
|
|
802
|
+
tournamentId: stringSchema('Only matches at this tournament edition, e.g. "atp_2026_560".', 'atp_2026_560'),
|
|
803
|
+
limit: limitSchema({ default: 20, max: 50, description: 'Match rows (default 20, max 50).' }),
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
handler: async (args, ctx) => {
|
|
807
|
+
const started = Date.now();
|
|
808
|
+
const requestId = newRequestId();
|
|
809
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
810
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
811
|
+
return errorEnvelope({
|
|
812
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
813
|
+
message: gameParse.error ?? 'game is required',
|
|
814
|
+
game: null,
|
|
815
|
+
source: 'player_matches',
|
|
816
|
+
requestId,
|
|
817
|
+
tookMs: Date.now() - started,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
const game = gameParse.game;
|
|
821
|
+
if (game !== 'tennis') {
|
|
822
|
+
return errorEnvelope({
|
|
823
|
+
code: 'NOT_IMPLEMENTED',
|
|
824
|
+
message: `player_matches is tennis-only (game '${game}' has its own match-log shape)`,
|
|
825
|
+
game,
|
|
826
|
+
source: 'player_matches',
|
|
827
|
+
requestId,
|
|
828
|
+
tookMs: Date.now() - started,
|
|
829
|
+
recover: ['Use match_summary or call_api for this game', 'Retry with game tennis'],
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
|
|
833
|
+
if (!playerId) {
|
|
834
|
+
return errorEnvelope({
|
|
835
|
+
code: 'VALIDATION',
|
|
836
|
+
message: 'playerId is required',
|
|
837
|
+
game,
|
|
838
|
+
source: 'player_matches',
|
|
839
|
+
requestId,
|
|
840
|
+
tookMs: Date.now() - started,
|
|
841
|
+
recover: ['Find an id with search_entities or resolve_entity'],
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
845
|
+
const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
846
|
+
const tournamentId = typeof args.tournamentId === 'string' && args.tournamentId.trim()
|
|
847
|
+
? args.tournamentId.trim()
|
|
848
|
+
: '';
|
|
849
|
+
const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/matches`, {
|
|
850
|
+
query: {
|
|
851
|
+
limit,
|
|
852
|
+
...(surfaceRaw ? { surface: surfaceRaw } : {}),
|
|
853
|
+
...(tournamentId ? { tournament_id: tournamentId } : {}),
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
if (!res.ok) {
|
|
857
|
+
return errorEnvelope({
|
|
858
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
859
|
+
message: `Player match log failed for '${playerId}' (HTTP ${res.status})`,
|
|
860
|
+
game,
|
|
861
|
+
source: 'player_matches',
|
|
862
|
+
requestId,
|
|
863
|
+
tookMs: Date.now() - started,
|
|
864
|
+
upstreamCalls: 1,
|
|
865
|
+
httpStatus: res.status,
|
|
866
|
+
rateLimit: res.headers,
|
|
867
|
+
recover: ['Check the player id with search_entities'],
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
const root = asRecord(res.data);
|
|
871
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
872
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
873
|
+
const allRows = Array.isArray(body.items) ? body.items : [];
|
|
874
|
+
/**
|
|
875
|
+
* Derive the player's display name from their own side of any match.
|
|
876
|
+
*
|
|
877
|
+
* The match-log payload carries no player_name at all (verified against the
|
|
878
|
+
* live route: top-level keys are just items/total/page/page_size/has_next/
|
|
879
|
+
* has_prev/success/total_pages), so the earlier version titled every log
|
|
880
|
+
* "<id> match log" and reported playerName: null. Each row does carry the
|
|
881
|
+
* winner and loser objects, so whichever side matches the requested id gives
|
|
882
|
+
* a real name with no extra upstream call.
|
|
883
|
+
*/
|
|
884
|
+
let derivedName = null;
|
|
885
|
+
for (const row of allRows) {
|
|
886
|
+
const r = asRecord(row) ?? {};
|
|
887
|
+
const w = asRecord(r.winner) ?? {};
|
|
888
|
+
const l = asRecord(r.loser) ?? {};
|
|
889
|
+
if (pickString(r.winner_id, w.id) === playerId) {
|
|
890
|
+
derivedName = pickString(w.name) ?? null;
|
|
891
|
+
}
|
|
892
|
+
else if (pickString(r.loser_id, l.id) === playerId) {
|
|
893
|
+
derivedName = pickString(l.name) ?? null;
|
|
894
|
+
}
|
|
895
|
+
if (derivedName)
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* The route ignores ?limit= (verified: limit=5 returned 20), so the bound is
|
|
900
|
+
* applied here. total comes from the upstream header so callers still learn
|
|
901
|
+
* how many matches exist beyond this page.
|
|
902
|
+
*/
|
|
903
|
+
const total = typeof body.total === 'number' ? body.total : allRows.length;
|
|
904
|
+
const rows = allRows.slice(0, limit).map((row) => {
|
|
905
|
+
const r = asRecord(row) ?? {};
|
|
906
|
+
const winner = asRecord(r.winner) ?? {};
|
|
907
|
+
const loser = asRecord(r.loser) ?? {};
|
|
908
|
+
const winnerId = pickString(r.winner_id, winner.id);
|
|
909
|
+
const lost = winnerId !== null && winnerId !== playerId;
|
|
910
|
+
// Opponent is whichever side is not this player. When the row's winner
|
|
911
|
+
// does not match the requested id the player lost, so the opponent is the
|
|
912
|
+
// winner.
|
|
913
|
+
const opponent = lost ? winner : loser;
|
|
914
|
+
return {
|
|
915
|
+
matchId: pickString(r.id, r.match_id) ?? null,
|
|
916
|
+
date: pickString(r.match_date, r.date) ?? null,
|
|
917
|
+
tournamentId: pickString(r.tournament_id) ?? null,
|
|
918
|
+
tournamentName: pickString(r.tournament_name, r.name) ?? null,
|
|
919
|
+
level: pickString(r.level) ?? null,
|
|
920
|
+
category: pickString(r.category) ?? null,
|
|
921
|
+
surface: pickString(r.surface) ?? null,
|
|
922
|
+
round: pickString(r.round) ?? null,
|
|
923
|
+
roundName: pickString(r.round_name) ?? null,
|
|
924
|
+
bestOf: num(r.best_of),
|
|
925
|
+
score: pickString(r.score, r.score_raw) ?? null,
|
|
926
|
+
outcome: pickString(r.outcome, r.status) ?? null,
|
|
927
|
+
result: lost ? 'L' : 'W',
|
|
928
|
+
opponent: {
|
|
929
|
+
id: pickString(opponent.id) ?? null,
|
|
930
|
+
name: pickString(opponent.name) ?? null,
|
|
931
|
+
country: pickString(opponent.ioc) ?? null,
|
|
932
|
+
rank: num(opponent.rank),
|
|
933
|
+
seed: opponent.seed ?? null,
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
});
|
|
937
|
+
return successEnvelope({
|
|
938
|
+
pagination: {
|
|
939
|
+
limit,
|
|
940
|
+
offset: 0,
|
|
941
|
+
total,
|
|
942
|
+
hasMore: total > rows.length,
|
|
943
|
+
nextCursor: null,
|
|
944
|
+
prevCursor: null,
|
|
945
|
+
},
|
|
946
|
+
source: 'player_matches',
|
|
947
|
+
game,
|
|
948
|
+
requestId,
|
|
949
|
+
tookMs: Date.now() - started,
|
|
950
|
+
upstreamCalls: 1,
|
|
951
|
+
rateLimit: res.headers,
|
|
952
|
+
entities: { games: [game], ids: { playerId } },
|
|
953
|
+
data: {
|
|
954
|
+
title: `${derivedName ?? playerId} match log`,
|
|
955
|
+
playerId,
|
|
956
|
+
playerName: derivedName,
|
|
957
|
+
surfaceFilter: surfaceRaw || null,
|
|
958
|
+
tournamentFilter: tournamentId || null,
|
|
959
|
+
matches: rows,
|
|
960
|
+
total,
|
|
961
|
+
// Named explicitly because the bound is applied locally: the upstream
|
|
962
|
+
// route returns everything and ignores ?limit=.
|
|
963
|
+
returned: rows.length,
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
export const playerStats = {
|
|
969
|
+
name: 'player_stats',
|
|
970
|
+
description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
|
|
971
|
+
|
|
972
|
+
When to use:
|
|
973
|
+
- "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
|
|
974
|
+
|
|
975
|
+
Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
|
|
976
|
+
|
|
977
|
+
Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
|
|
978
|
+
|
|
979
|
+
Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
|
|
980
|
+
Upstream cost: 1.`,
|
|
981
|
+
inputSchema: {
|
|
982
|
+
type: 'object',
|
|
983
|
+
additionalProperties: false,
|
|
984
|
+
required: ['game', 'playerId'],
|
|
985
|
+
properties: {
|
|
986
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
987
|
+
playerId: stringSchema('Tennis player id, e.g. "atp_207989".', 'atp_207989'),
|
|
988
|
+
surface: stringSchema('Filter to one surface: Hard, Clay, Grass, or Carpet.', 'Clay'),
|
|
989
|
+
yearFrom: { type: 'integer', minimum: 1877, maximum: 2100, description: 'Inclusive start year, e.g. 2020.' },
|
|
990
|
+
yearTo: { type: 'integer', minimum: 1877, maximum: 2100, description: 'Inclusive end year, e.g. 2026.' },
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
handler: async (args, ctx) => {
|
|
994
|
+
const started = Date.now();
|
|
995
|
+
const requestId = newRequestId();
|
|
996
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
997
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
998
|
+
return errorEnvelope({
|
|
999
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
1000
|
+
message: gameParse.error ?? 'game is required',
|
|
1001
|
+
game: null,
|
|
1002
|
+
source: 'player_stats',
|
|
1003
|
+
requestId,
|
|
1004
|
+
tookMs: Date.now() - started,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
const game = gameParse.game;
|
|
1008
|
+
if (game !== 'tennis') {
|
|
1009
|
+
return errorEnvelope({
|
|
1010
|
+
code: 'NOT_IMPLEMENTED',
|
|
1011
|
+
message: `player_stats is tennis-only (game '${game}' exposes stats elsewhere)`,
|
|
1012
|
+
game,
|
|
1013
|
+
source: 'player_stats',
|
|
1014
|
+
requestId,
|
|
1015
|
+
tookMs: Date.now() - started,
|
|
1016
|
+
recover: ['Use player_profile for this game', 'Retry with game tennis'],
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
|
|
1020
|
+
if (!playerId) {
|
|
1021
|
+
return errorEnvelope({
|
|
1022
|
+
code: 'VALIDATION',
|
|
1023
|
+
message: 'playerId is required',
|
|
1024
|
+
game,
|
|
1025
|
+
source: 'player_stats',
|
|
1026
|
+
requestId,
|
|
1027
|
+
tookMs: Date.now() - started,
|
|
1028
|
+
recover: ['Find an id with search_entities or resolve_entity'],
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
1032
|
+
const yf = typeof args.yearFrom === 'number' && Number.isFinite(args.yearFrom) ? Math.trunc(args.yearFrom) : null;
|
|
1033
|
+
const yt = typeof args.yearTo === 'number' && Number.isFinite(args.yearTo) ? Math.trunc(args.yearTo) : null;
|
|
1034
|
+
if (yf !== null && yt !== null && yf > yt) {
|
|
1035
|
+
return errorEnvelope({
|
|
1036
|
+
code: 'VALIDATION',
|
|
1037
|
+
message: `yearFrom (${yf}) must not be greater than yearTo (${yt})`,
|
|
1038
|
+
game,
|
|
1039
|
+
source: 'player_stats',
|
|
1040
|
+
requestId,
|
|
1041
|
+
tookMs: Date.now() - started,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/stats`, {
|
|
1045
|
+
query: {
|
|
1046
|
+
...(surfaceRaw ? { surface: surfaceRaw } : {}),
|
|
1047
|
+
...(yf !== null ? { year_from: yf } : {}),
|
|
1048
|
+
...(yt !== null ? { year_to: yt } : {}),
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
if (!res.ok) {
|
|
1052
|
+
return errorEnvelope({
|
|
1053
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
1054
|
+
message: `Player stats failed for '${playerId}' (HTTP ${res.status})`,
|
|
1055
|
+
game,
|
|
1056
|
+
source: 'player_stats',
|
|
1057
|
+
requestId,
|
|
1058
|
+
tookMs: Date.now() - started,
|
|
1059
|
+
upstreamCalls: 1,
|
|
1060
|
+
httpStatus: res.status,
|
|
1061
|
+
rateLimit: res.headers,
|
|
1062
|
+
recover: ['Check the player id with search_entities'],
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
const root = asRecord(res.data);
|
|
1066
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
1067
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
1068
|
+
const summary = asRecord(body.career_summary) ?? {};
|
|
1069
|
+
const filters = asRecord(body.filters) ?? {};
|
|
1070
|
+
/**
|
|
1071
|
+
* Map an arbitrary {label: value} breakdown without assuming the upstream
|
|
1072
|
+
* key set: the endpoint has grown keys before and a hardcoded list would
|
|
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.
|
|
1103
|
+
*/
|
|
1104
|
+
const bucketBlock = (value, fields) => {
|
|
1105
|
+
const rec = asRecord(value);
|
|
1106
|
+
if (!rec)
|
|
1107
|
+
return null;
|
|
1108
|
+
const out = {};
|
|
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
|
+
}
|
|
1123
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
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
|
+
}
|
|
1153
|
+
return successEnvelope({
|
|
1154
|
+
source: 'player_stats',
|
|
1155
|
+
game,
|
|
1156
|
+
requestId,
|
|
1157
|
+
tookMs: Date.now() - started,
|
|
1158
|
+
upstreamCalls: 1,
|
|
1159
|
+
rateLimit: res.headers,
|
|
1160
|
+
entities: { games: [game], ids: { playerId } },
|
|
1161
|
+
data: {
|
|
1162
|
+
title: `${pickString(body.player_name) ?? playerId} career statistics`,
|
|
1163
|
+
playerId: pickString(body.player_id) ?? playerId,
|
|
1164
|
+
playerName: pickString(body.player_name) ?? null,
|
|
1165
|
+
filters: {
|
|
1166
|
+
surface: pickString(filters.surface) ?? null,
|
|
1167
|
+
level: pickString(filters.level) ?? null,
|
|
1168
|
+
yearFrom: num(filters.year_from),
|
|
1169
|
+
yearTo: num(filters.year_to),
|
|
1170
|
+
},
|
|
1171
|
+
careerSummary: {
|
|
1172
|
+
matchesPlayed: num(summary.matches_played),
|
|
1173
|
+
matchesWon: num(summary.matches_won),
|
|
1174
|
+
matchesLost: num(summary.matches_lost),
|
|
1175
|
+
winPercentage: num(summary.win_percentage),
|
|
1176
|
+
titles: num(summary.titles_count),
|
|
1177
|
+
grandSlamTitles: num(summary.grand_slam_titles),
|
|
1178
|
+
mastersTitles: num(summary.masters_titles),
|
|
1179
|
+
finalsReached: num(summary.finals_reached),
|
|
1180
|
+
},
|
|
1181
|
+
bySurface,
|
|
1182
|
+
byLevel,
|
|
1183
|
+
servingStats,
|
|
1184
|
+
returnStats,
|
|
1185
|
+
clutchAndSituational,
|
|
1186
|
+
grandSlamBreakdown,
|
|
1187
|
+
...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
},
|
|
1191
|
+
};
|
|
1192
|
+
export const playerTools = [playerProfile, playerForm, playerMatches, playerStats];
|
package/dist/tools/rankings.js
CHANGED
|
@@ -82,6 +82,8 @@ Do not use when: current top-N snapshot → standings with game tennis; player h
|
|
|
82
82
|
|
|
83
83
|
Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
|
|
84
84
|
|
|
85
|
+
The two lists are NOT always one week apart: previousDate is whatever the archive holds next, and the archive has holes. Read gapDays / comparisonWindow (and comparisonsAreWeekly) before describing a delta as weekly — a warning is raised whenever the gap exceeds ten days.
|
|
86
|
+
|
|
85
87
|
Parallel-safe: yes. Upstream cost: 1.`,
|
|
86
88
|
inputSchema: {
|
|
87
89
|
type: 'object',
|
|
@@ -195,6 +197,39 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
195
197
|
const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
|
|
196
198
|
const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
|
|
197
199
|
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
200
|
+
/**
|
|
201
|
+
* How far apart the two lists actually are.
|
|
202
|
+
*
|
|
203
|
+
* `previous_date` is simply the next list the archive holds — it is NOT
|
|
204
|
+
* guaranteed to be last week. On 2026-09-12 it was 2026-06-08 against a
|
|
205
|
+
* current 2026-08-31: an 84-day gap, because the weekly archive ends
|
|
206
|
+
* 2026-06-08 and the only snapshots after it are the current ESPN-sourced
|
|
207
|
+
* pair. Calling that "week-over-week movers" told every caller that a
|
|
208
|
+
* three-month climb was a one-week climb. The interval is measured and
|
|
209
|
+
* reported instead of assumed.
|
|
210
|
+
*/
|
|
211
|
+
const rankingDate = pickString(body.ranking_date) ?? null;
|
|
212
|
+
const previousDate = pickString(body.previous_date) ?? null;
|
|
213
|
+
const gapDays = (() => {
|
|
214
|
+
if (!rankingDate || !previousDate)
|
|
215
|
+
return null;
|
|
216
|
+
const a = Date.parse(rankingDate);
|
|
217
|
+
const b = Date.parse(previousDate);
|
|
218
|
+
if (!Number.isFinite(a) || !Number.isFinite(b))
|
|
219
|
+
return null;
|
|
220
|
+
return Math.round((a - b) / 86_400_000);
|
|
221
|
+
})();
|
|
222
|
+
const comparisonWindow = gapDays === null
|
|
223
|
+
? null
|
|
224
|
+
: gapDays <= 8
|
|
225
|
+
? 'week-over-week'
|
|
226
|
+
: `${Math.round(gapDays / 7)}-week gap`;
|
|
227
|
+
const warnings = [];
|
|
228
|
+
if (gapDays !== null && gapDays > 10) {
|
|
229
|
+
warnings.push(`The compared lists are ${gapDays} days apart (${previousDate} -> ${rankingDate}), not one week. ` +
|
|
230
|
+
`Every "movement" here accumulated over ${Math.round(gapDays / 7)} weeks; there is no published ` +
|
|
231
|
+
`ranking snapshot strictly between those two dates. Treat the deltas as period-over-period, not weekly.`);
|
|
232
|
+
}
|
|
198
233
|
return successEnvelope({
|
|
199
234
|
pagination: {
|
|
200
235
|
limit,
|
|
@@ -210,13 +245,19 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
210
245
|
tookMs: Date.now() - started,
|
|
211
246
|
upstreamCalls: 1,
|
|
212
247
|
rateLimit: res.headers,
|
|
248
|
+
warnings: warnings.length ? warnings : undefined,
|
|
213
249
|
data: {
|
|
214
250
|
title: `${tourParse.tour} ranking movers (${dirParse.direction})`,
|
|
215
251
|
tour: tourParse.tour,
|
|
216
252
|
direction: dirParse.direction,
|
|
217
253
|
within,
|
|
218
|
-
rankingDate
|
|
219
|
-
previousDate
|
|
254
|
+
rankingDate,
|
|
255
|
+
previousDate,
|
|
256
|
+
/** Real interval between the two lists; null when only one list exists. */
|
|
257
|
+
gapDays,
|
|
258
|
+
/** 'week-over-week' only when the gap really is about a week. */
|
|
259
|
+
comparisonWindow,
|
|
260
|
+
comparisonsAreWeekly: gapDays !== null && gapDays <= 8,
|
|
220
261
|
items,
|
|
221
262
|
newEntries,
|
|
222
263
|
dropped,
|
|
@@ -225,4 +266,174 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
225
266
|
});
|
|
226
267
|
},
|
|
227
268
|
};
|
|
228
|
-
export const
|
|
269
|
+
export const playerRankingsHistory = {
|
|
270
|
+
name: 'player_rankings_history',
|
|
271
|
+
description: `Tennis player ranking trajectory: every published ranking date for one player, plus career-high and weeks at No.1.
|
|
272
|
+
|
|
273
|
+
When to use:
|
|
274
|
+
- "Show Alcaraz's ranking over time"; career-high rank and when it was reached; how a player climbed; plotting a rank chart.
|
|
275
|
+
|
|
276
|
+
Prefer over: standings (latest snapshot only); rankings_movers (week-over-week delta for a whole tour); player_profile (has career-high but no per-date series).
|
|
277
|
+
|
|
278
|
+
Do not use when: the current top-N table → standings with game tennis.
|
|
279
|
+
|
|
280
|
+
Tennis-only. Rows arrive oldest-first (chronological), which is the order a chart
|
|
281
|
+
wants; the newest entries are the tail. limit keeps the most RECENT N rows (the
|
|
282
|
+
route itself ignores ?limit=, so the bound is applied here) — hasMore and
|
|
283
|
+
oldestReturned/newestReturned say exactly which window came back, and
|
|
284
|
+
omittedOlder counts the rows left behind at the old end.
|
|
285
|
+
|
|
286
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
287
|
+
inputSchema: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
required: ['game', 'playerId'],
|
|
291
|
+
properties: {
|
|
292
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
293
|
+
playerId: stringSchema('Tennis player id, e.g. "atp_207989" or "wta_214544".', 'atp_207989'),
|
|
294
|
+
since: stringSchema('Only rows on or after this date (YYYY-MM-DD).', '2026-01-01'),
|
|
295
|
+
limit: limitSchema({ default: 20, max: 500, description: 'Max history rows (default 20, max 500).' }),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
handler: async (args, ctx) => {
|
|
299
|
+
const started = Date.now();
|
|
300
|
+
const requestId = newRequestId();
|
|
301
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
302
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
303
|
+
return errorEnvelope({
|
|
304
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
305
|
+
message: gameParse.error ?? 'game is required',
|
|
306
|
+
game: null,
|
|
307
|
+
source: 'player_rankings_history',
|
|
308
|
+
requestId,
|
|
309
|
+
tookMs: Date.now() - started,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const game = gameParse.game;
|
|
313
|
+
if (game !== 'tennis') {
|
|
314
|
+
return errorEnvelope({
|
|
315
|
+
code: 'NOT_IMPLEMENTED',
|
|
316
|
+
message: `player_rankings_history is tennis-only (game '${game}' has no ranking-history endpoint)`,
|
|
317
|
+
game,
|
|
318
|
+
source: 'player_rankings_history',
|
|
319
|
+
requestId,
|
|
320
|
+
tookMs: Date.now() - started,
|
|
321
|
+
recover: ['Use standings for the latest snapshot of this game', 'Retry with game tennis'],
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
|
|
325
|
+
if (!playerId) {
|
|
326
|
+
return errorEnvelope({
|
|
327
|
+
code: 'VALIDATION',
|
|
328
|
+
message: 'playerId is required',
|
|
329
|
+
game,
|
|
330
|
+
source: 'player_rankings_history',
|
|
331
|
+
requestId,
|
|
332
|
+
tookMs: Date.now() - started,
|
|
333
|
+
recover: ['Find an id with search_entities or resolve_entity'],
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const sinceRaw = typeof args.since === 'string' ? args.since.trim() : '';
|
|
337
|
+
if (sinceRaw && !/^\d{4}-\d{2}-\d{2}$/.test(sinceRaw)) {
|
|
338
|
+
return errorEnvelope({
|
|
339
|
+
code: 'VALIDATION',
|
|
340
|
+
message: `since must be YYYY-MM-DD (got '${args.since}')`,
|
|
341
|
+
game,
|
|
342
|
+
source: 'player_rankings_history',
|
|
343
|
+
requestId,
|
|
344
|
+
tookMs: Date.now() - started,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const limit = clampInt(args.limit, 20, 1, 500);
|
|
348
|
+
const res = await fetchJson(ctx, `/tennis/rankings/history/${encodeURIComponent(playerId)}`, {
|
|
349
|
+
query: { limit },
|
|
350
|
+
});
|
|
351
|
+
if (!res.ok) {
|
|
352
|
+
return errorEnvelope({
|
|
353
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
354
|
+
message: `Ranking history failed for '${playerId}' (HTTP ${res.status})`,
|
|
355
|
+
game,
|
|
356
|
+
source: 'player_rankings_history',
|
|
357
|
+
requestId,
|
|
358
|
+
tookMs: Date.now() - started,
|
|
359
|
+
upstreamCalls: 1,
|
|
360
|
+
httpStatus: res.status,
|
|
361
|
+
rateLimit: res.headers,
|
|
362
|
+
recover: ['Check the player id with search_entities'],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const root = asRecord(res.data) ?? {};
|
|
366
|
+
const body = asRecord(root.data) ?? root;
|
|
367
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
368
|
+
const allHistory = (Array.isArray(body.history) ? body.history : []).map((row) => {
|
|
369
|
+
const r = asRecord(row) ?? {};
|
|
370
|
+
return {
|
|
371
|
+
date: pickString(r.date) ?? null,
|
|
372
|
+
rank: num(r.rank),
|
|
373
|
+
points: num(r.points),
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
const inWindow = sinceRaw
|
|
377
|
+
? allHistory.filter((h) => h.date !== null && h.date >= sinceRaw)
|
|
378
|
+
: allHistory;
|
|
379
|
+
/**
|
|
380
|
+
* The route IGNORES ?limit= — verified: `?limit=4` returned all 342 rows of
|
|
381
|
+
* Alcaraz's career with total:342 and hasMore:false. Asking for 4 and
|
|
382
|
+
* receiving 342 is not a bound, it is a payload the caller cannot control,
|
|
383
|
+
* so the bound is applied here.
|
|
384
|
+
*
|
|
385
|
+
* Rows arrive oldest-first because that is the order a chart wants. limit
|
|
386
|
+
* therefore keeps the most RECENT N (the tail), matching every other tool
|
|
387
|
+
* in this server: `player_matches` returns the newest rows and reports
|
|
388
|
+
* hasMore when older ones exist. The older end is what gets dropped, and
|
|
389
|
+
* oldestReturned/newestReturned say exactly which window came back.
|
|
390
|
+
*/
|
|
391
|
+
const total = inWindow.length;
|
|
392
|
+
const history = total > limit ? inWindow.slice(total - limit) : inWindow;
|
|
393
|
+
// Best rank actually present in the returned window, so a caller can label a
|
|
394
|
+
// chart without recomputing. Null when the window is empty.
|
|
395
|
+
const best = history.reduce((acc, h) => {
|
|
396
|
+
if (h.rank === null)
|
|
397
|
+
return acc;
|
|
398
|
+
if (!acc || acc.rank === null || h.rank < acc.rank)
|
|
399
|
+
return { date: h.date, rank: h.rank };
|
|
400
|
+
return acc;
|
|
401
|
+
}, null);
|
|
402
|
+
return successEnvelope({
|
|
403
|
+
pagination: {
|
|
404
|
+
limit,
|
|
405
|
+
offset: total - history.length,
|
|
406
|
+
total,
|
|
407
|
+
hasMore: total > history.length,
|
|
408
|
+
nextCursor: null,
|
|
409
|
+
prevCursor: null,
|
|
410
|
+
},
|
|
411
|
+
source: 'player_rankings_history',
|
|
412
|
+
game,
|
|
413
|
+
requestId,
|
|
414
|
+
tookMs: Date.now() - started,
|
|
415
|
+
upstreamCalls: 1,
|
|
416
|
+
rateLimit: res.headers,
|
|
417
|
+
data: {
|
|
418
|
+
title: `${pickString(body.player_name) ?? playerId} ranking history`,
|
|
419
|
+
playerId: pickString(body.player_id) ?? playerId,
|
|
420
|
+
playerName: pickString(body.player_name) ?? null,
|
|
421
|
+
tour: pickString(body.tour) ?? null,
|
|
422
|
+
careerHighRank: num(body.career_high_rank),
|
|
423
|
+
careerHighDate: pickString(body.career_high_date) ?? null,
|
|
424
|
+
weeksAtNo1: num(body.total_weeks_at_no_1),
|
|
425
|
+
since: sinceRaw || null,
|
|
426
|
+
bestRankInWindow: best,
|
|
427
|
+
history,
|
|
428
|
+
// The bound is applied locally because the route ignores ?limit=; state
|
|
429
|
+
// it so a caller does not read a short series as a short career.
|
|
430
|
+
totalInWindow: total,
|
|
431
|
+
returned: history.length,
|
|
432
|
+
omittedOlder: total - history.length,
|
|
433
|
+
oldestReturned: history.length ? history[0].date : null,
|
|
434
|
+
newestReturned: history.length ? history[history.length - 1].date : null,
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
export const rankingsTools = [rankingsMovers, playerRankingsHistory];
|