cito-mcp 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -4
- package/dist/client.js +14 -0
- package/dist/index.js +45 -6
- package/dist/instructions.js +1 -0
- package/dist/tools/index.js +3 -2
- package/dist/tools/insight.js +83 -12
- package/dist/tools/live.js +176 -25
- package/dist/tools/match.js +76 -29
- package/dist/tools/meta.js +182 -8
- package/dist/tools/normalize.js +205 -10
- package/dist/tools/player.js +28 -2
- package/dist/tools/resolve.js +66 -37
- package/dist/tools/standings.js +34 -1
- package/dist/tools/team.js +111 -47
- package/dist/tools/types.js +73 -0
- package/dist/version.js +29 -0
- package/package.json +3 -2
package/dist/tools/match.js
CHANGED
|
@@ -35,8 +35,55 @@ function matchCore(game, matchId, raw) {
|
|
|
35
35
|
event: m.event,
|
|
36
36
|
league: m.league,
|
|
37
37
|
method: pickString(r.method, r.resultMethod) ?? null,
|
|
38
|
-
winner:
|
|
38
|
+
winner: (() => {
|
|
39
|
+
// UFC rows carry an explicit winner slug/fighter ref.
|
|
40
|
+
const explicit = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
|
|
41
|
+
if (explicit)
|
|
42
|
+
return explicit;
|
|
43
|
+
if (game === 'ufc')
|
|
44
|
+
return null;
|
|
45
|
+
// CS2 rows expose winnerTeamId instead; map it to a side, else fall back to scores.
|
|
46
|
+
const sideLabel = (side) => side ? pickString(side.slug, side.id, side.name) ?? null : null;
|
|
47
|
+
const winnerTeamId = pickString(r.winnerTeamId, r.winner_team_id);
|
|
48
|
+
if (winnerTeamId) {
|
|
49
|
+
const t1Ids = [m.team1?.id, m.team1?.slug, pickString(r.team1Id, r.team1_id)].filter(Boolean).map(String);
|
|
50
|
+
const t2Ids = [m.team2?.id, m.team2?.slug, pickString(r.team2Id, r.team2_id)].filter(Boolean).map(String);
|
|
51
|
+
if (t1Ids.includes(winnerTeamId))
|
|
52
|
+
return sideLabel(m.team1);
|
|
53
|
+
if (t2Ids.includes(winnerTeamId))
|
|
54
|
+
return sideLabel(m.team2);
|
|
55
|
+
}
|
|
56
|
+
if (s1 != null && s2 != null && s1 !== s2)
|
|
57
|
+
return sideLabel(s1 > s2 ? m.team1 : m.team2);
|
|
58
|
+
return null;
|
|
59
|
+
})(),
|
|
39
60
|
weightClass: pickString(r.weightClass, r.division) ?? null,
|
|
61
|
+
referee: (() => {
|
|
62
|
+
const nested = asRecord(r.referee);
|
|
63
|
+
if (nested) {
|
|
64
|
+
const joined = [nested.firstName, nested.lastName].filter(Boolean).join(' ').trim();
|
|
65
|
+
const name = pickString(nested.name, joined || undefined) ?? null;
|
|
66
|
+
const id = pickString(nested.id, nested.refereeId) ?? null;
|
|
67
|
+
if (name || id) {
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
name,
|
|
71
|
+
firstName: pickString(nested.firstName) ?? null,
|
|
72
|
+
lastName: pickString(nested.lastName) ?? null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const name = pickString(r.refereeName) ?? null;
|
|
77
|
+
const id = pickString(r.refereeId) ?? null;
|
|
78
|
+
if (!name && !id)
|
|
79
|
+
return null;
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
name,
|
|
83
|
+
firstName: pickString(r.refereeFirstName) ?? null,
|
|
84
|
+
lastName: pickString(r.refereeLastName) ?? null,
|
|
85
|
+
};
|
|
86
|
+
})(),
|
|
40
87
|
rawStatus: pickString(r.status, r.state) ?? null,
|
|
41
88
|
};
|
|
42
89
|
}
|
|
@@ -56,18 +103,18 @@ function primaryPath(game, matchId) {
|
|
|
56
103
|
}
|
|
57
104
|
export const matchSummary = {
|
|
58
105
|
name: 'match_summary',
|
|
59
|
-
description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
|
|
60
|
-
|
|
61
|
-
When to use:
|
|
62
|
-
- Match recap / default match UI
|
|
63
|
-
- After user selects a live or completed matchId
|
|
64
|
-
|
|
65
|
-
Prefer over match_details for chat answers and default UIs.
|
|
66
|
-
Prefer match_details for timelines, full map trees, live state, advanced packages.
|
|
67
|
-
|
|
68
|
-
Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
|
|
69
|
-
|
|
70
|
-
Parallel-safe: yes. Upstream cost: 2–5.
|
|
106
|
+
description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
|
|
107
|
+
|
|
108
|
+
When to use:
|
|
109
|
+
- Match recap / default match UI
|
|
110
|
+
- After user selects a live or completed matchId
|
|
111
|
+
|
|
112
|
+
Prefer over match_details for chat answers and default UIs.
|
|
113
|
+
Prefer match_details for timelines, full map trees, live state, advanced packages.
|
|
114
|
+
|
|
115
|
+
Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
|
|
116
|
+
|
|
117
|
+
Parallel-safe: yes. Upstream cost: 2–5.
|
|
71
118
|
Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
|
|
72
119
|
inputSchema: {
|
|
73
120
|
type: 'object',
|
|
@@ -251,22 +298,22 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
251
298
|
};
|
|
252
299
|
export const matchDetails = {
|
|
253
300
|
name: 'match_details',
|
|
254
|
-
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
255
|
-
|
|
256
|
-
When to use:
|
|
257
|
-
- Analyst deep dive
|
|
258
|
-
- Live in-game window (LoL/CS2/UFC)
|
|
259
|
-
- Full demo list
|
|
260
|
-
|
|
261
|
-
Prefer over match_summary only when summary is insufficient.
|
|
262
|
-
Prefer match_summary for short answers and default cards.
|
|
263
|
-
|
|
264
|
-
Do not use when: first-pass live board (use live_matches + match_summary).
|
|
265
|
-
|
|
266
|
-
Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
|
|
267
|
-
If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
|
|
268
|
-
|
|
269
|
-
Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
|
|
301
|
+
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
302
|
+
|
|
303
|
+
When to use:
|
|
304
|
+
- Analyst deep dive
|
|
305
|
+
- Live in-game window (LoL/CS2/UFC)
|
|
306
|
+
- Full demo list
|
|
307
|
+
|
|
308
|
+
Prefer over match_summary only when summary is insufficient.
|
|
309
|
+
Prefer match_summary for short answers and default cards.
|
|
310
|
+
|
|
311
|
+
Do not use when: first-pass live board (use live_matches + match_summary).
|
|
312
|
+
|
|
313
|
+
Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
|
|
314
|
+
If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
|
|
315
|
+
|
|
316
|
+
Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
|
|
270
317
|
Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
|
|
271
318
|
inputSchema: {
|
|
272
319
|
type: 'object',
|
package/dist/tools/meta.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
7
|
-
|
|
7
|
+
import { PACKAGE_VERSION as CATALOG_VERSION } from '../version.js';
|
|
8
8
|
const TOOL_CATALOG = [
|
|
9
9
|
{
|
|
10
10
|
name: 'list_capabilities',
|
|
@@ -88,10 +88,10 @@ const TOOL_CATALOG = [
|
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
name: 'player_profile',
|
|
91
|
-
outcome: 'Player/fighter identity + recent form',
|
|
91
|
+
outcome: 'Player/fighter identity + recent form, including images (headshot, body, CORS-safe proxied)',
|
|
92
92
|
parallelSafe: true,
|
|
93
93
|
games: [...PRIMARY_GAMES],
|
|
94
|
-
jobs: ['player_form'],
|
|
94
|
+
jobs: ['player_form', 'media'],
|
|
95
95
|
exampleArgs: { game: 'cs2', playerId: 'cs2-player-1', recentLimit: 10 },
|
|
96
96
|
preferOver: ['manual multi-call career/trends via call_api'],
|
|
97
97
|
doNotUse: 'Full team roster → team_profile; unresolved name → resolve_entity',
|
|
@@ -138,14 +138,29 @@ const TOOL_CATALOG = [
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
name: 'event_card',
|
|
141
|
-
outcome: 'Event / fight-night card: identity + bout
|
|
141
|
+
outcome: 'Event / fight-night card: identity + bout list, card-ordered (main event first), ' +
|
|
142
|
+
'each corner carrying photos, record, nickname, country and weight class (+ optional standings)',
|
|
142
143
|
parallelSafe: true,
|
|
143
144
|
games: [...PRIMARY_GAMES],
|
|
144
|
-
jobs: ['event_card', 'schedule', 'app_scaffold'],
|
|
145
|
+
jobs: ['event_card', 'schedule', 'media', 'app_scaffold'],
|
|
145
146
|
exampleArgs: { game: 'ufc', eventIdOrSlug: 'ufc-300', includeBouts: true },
|
|
146
|
-
preferOver: [
|
|
147
|
+
preferOver: [
|
|
148
|
+
'call_api /ufc/events + bout expansion',
|
|
149
|
+
'N+1 match_summary for card list',
|
|
150
|
+
'N+1 call_api per fighter for headshots — corners already include images',
|
|
151
|
+
],
|
|
147
152
|
doNotUse: 'Live-only strip → live_matches; single match recap → match_summary',
|
|
148
153
|
},
|
|
154
|
+
{
|
|
155
|
+
name: 'list_routes',
|
|
156
|
+
outcome: 'Index of raw REST routes from the live OpenAPI spec (method, path, summary)',
|
|
157
|
+
parallelSafe: true,
|
|
158
|
+
games: [...PRIMARY_GAMES, 'fortnite'],
|
|
159
|
+
jobs: ['app_scaffold'],
|
|
160
|
+
exampleArgs: { game: 'ufc', q: 'rankings' },
|
|
161
|
+
preferOver: ['guessing a REST path for call_api', 'fetching the full 90KB spec by hand'],
|
|
162
|
+
doNotUse: 'A curated tool covers the outcome → list_capabilities',
|
|
163
|
+
},
|
|
149
164
|
{
|
|
150
165
|
name: 'call_api',
|
|
151
166
|
outcome: 'Allowlisted raw REST escape hatch (unshaped data.raw)',
|
|
@@ -167,6 +182,13 @@ const JOBS = [
|
|
|
167
182
|
{ id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
|
|
168
183
|
{ id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
|
|
169
184
|
{ id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
|
|
185
|
+
// Agents asked "does this API have photos?" and, finding no job for it,
|
|
186
|
+
// assumed no. Photos ship on every UFC corner and profile; make that findable.
|
|
187
|
+
{
|
|
188
|
+
id: 'media',
|
|
189
|
+
description: 'Headshots, full-body shots and team logos for visual UIs. UFC fighters carry headshotUrl / bodyImageUrl / imageUrl plus a CORS-safe proxiedImageUrl; use the proxied URL in a browser. Available on event_card corners and player_profile without any extra call.',
|
|
190
|
+
recommendedTools: ['event_card', 'player_profile', 'resolve_entity'],
|
|
191
|
+
},
|
|
170
192
|
{ id: 'app_scaffold', description: 'Design-time multi-screen prototype', recommendedTools: ['list_capabilities', 'api_health', 'live_matches'] },
|
|
171
193
|
];
|
|
172
194
|
const RECIPES = [
|
|
@@ -450,7 +472,18 @@ Example: { "includeGameProbes": true }`,
|
|
|
450
472
|
});
|
|
451
473
|
},
|
|
452
474
|
};
|
|
453
|
-
const ALLOWLIST_PREFIXES = [
|
|
475
|
+
const ALLOWLIST_PREFIXES = [
|
|
476
|
+
'/health',
|
|
477
|
+
'/lol',
|
|
478
|
+
'/cs2',
|
|
479
|
+
'/dota2',
|
|
480
|
+
'/cod',
|
|
481
|
+
'/ufc',
|
|
482
|
+
'/fortnite',
|
|
483
|
+
// The spec describes the surface call_api is allowed to reach; refusing to
|
|
484
|
+
// serve it left route discovery impossible except by guessing.
|
|
485
|
+
'/openapi.json',
|
|
486
|
+
];
|
|
454
487
|
function pathAllowed(path) {
|
|
455
488
|
if (!path.startsWith('/') || path.startsWith('//'))
|
|
456
489
|
return false;
|
|
@@ -604,6 +637,147 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
|
|
|
604
637
|
});
|
|
605
638
|
},
|
|
606
639
|
};
|
|
607
|
-
|
|
640
|
+
/**
|
|
641
|
+
* Games whose routes the published spec does not describe.
|
|
642
|
+
*
|
|
643
|
+
* The spec has 123 paths and zero under /lol, yet every LoL route works —
|
|
644
|
+
* api_health itself answers partly from /lol/leagues. An agent that treats the
|
|
645
|
+
* spec as the whole surface concludes LoL is unsupported and stops, so say so
|
|
646
|
+
* explicitly rather than returning an empty list that reads as a verdict.
|
|
647
|
+
*/
|
|
648
|
+
const SPEC_OMITS = {
|
|
649
|
+
lol: 'The published OpenAPI spec documents no /lol paths, but LoL routes exist and work. Use the curated LoL tools (live_matches, upcoming_schedule, team_profile, standings, player_profile); for raw access, /lol/* is allowlisted for call_api even though it is undocumented.',
|
|
650
|
+
};
|
|
651
|
+
export const listRoutes = {
|
|
652
|
+
name: 'list_routes',
|
|
653
|
+
description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
|
|
654
|
+
|
|
655
|
+
When to use:
|
|
656
|
+
- You need a long-tail path for call_api and do not want to guess
|
|
657
|
+
- Checking whether an endpoint exists before building around it
|
|
658
|
+
- Mapping what raw data backs a curated tool
|
|
659
|
+
|
|
660
|
+
Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
|
|
661
|
+
|
|
662
|
+
Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
|
|
663
|
+
|
|
664
|
+
Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
|
|
665
|
+
|
|
666
|
+
Parallel-safe: yes. Upstream cost: 1.
|
|
667
|
+
Example: { "game": "ufc", "q": "rankings" }`,
|
|
668
|
+
inputSchema: {
|
|
669
|
+
type: 'object',
|
|
670
|
+
additionalProperties: false,
|
|
671
|
+
properties: {
|
|
672
|
+
game: gameSchema({
|
|
673
|
+
allowAll: false,
|
|
674
|
+
description: 'Filter to one game prefix (also accepts fortnite). Omit for all routes.',
|
|
675
|
+
}),
|
|
676
|
+
q: stringSchema('Free-text filter over path and summary.', 'rankings'),
|
|
677
|
+
limit: {
|
|
678
|
+
type: 'integer',
|
|
679
|
+
minimum: 1,
|
|
680
|
+
maximum: 200,
|
|
681
|
+
default: 60,
|
|
682
|
+
description: 'Max routes to return (default 60, max 200).',
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
handler: async (args, ctx) => {
|
|
687
|
+
const started = Date.now();
|
|
688
|
+
const requestId = newRequestId();
|
|
689
|
+
// Accept fortnite here even though it is not a PRIMARY_GAME: it has 21
|
|
690
|
+
// documented paths and no curated tools, so it is exactly what call_api
|
|
691
|
+
// callers come looking for.
|
|
692
|
+
const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
|
|
693
|
+
if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite)$/)) {
|
|
694
|
+
return errorEnvelope({
|
|
695
|
+
code: 'UNSUPPORTED_GAME',
|
|
696
|
+
message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite`,
|
|
697
|
+
game: null,
|
|
698
|
+
source: 'list_routes',
|
|
699
|
+
requestId,
|
|
700
|
+
tookMs: Date.now() - started,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
const q = typeof args.q === 'string' ? args.q.toLowerCase().trim() : '';
|
|
704
|
+
const limit = Math.min(Math.max(Number(args.limit) || 60, 1), 200);
|
|
705
|
+
const res = await fetchJson(ctx, '/openapi.json');
|
|
706
|
+
if (!res.ok) {
|
|
707
|
+
return errorEnvelope({
|
|
708
|
+
code: mapHttpToCode(res.status),
|
|
709
|
+
message: `openapi.json HTTP ${res.status}`,
|
|
710
|
+
game: gameRaw || null,
|
|
711
|
+
source: 'list_routes',
|
|
712
|
+
requestId,
|
|
713
|
+
tookMs: Date.now() - started,
|
|
714
|
+
upstreamCalls: 1,
|
|
715
|
+
rateLimit: res.headers,
|
|
716
|
+
httpStatus: res.status,
|
|
717
|
+
recover: [
|
|
718
|
+
'Call list_capabilities for curated tools that need no route knowledge',
|
|
719
|
+
'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite',
|
|
720
|
+
],
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
const spec = (res.data ?? {});
|
|
724
|
+
const paths = (spec.paths ?? {});
|
|
725
|
+
const rows = [];
|
|
726
|
+
for (const [path, ops] of Object.entries(paths)) {
|
|
727
|
+
if (!ops || typeof ops !== 'object')
|
|
728
|
+
continue;
|
|
729
|
+
for (const [method, opRaw] of Object.entries(ops)) {
|
|
730
|
+
if (!/^(get|post|put|patch|delete)$/i.test(method))
|
|
731
|
+
continue;
|
|
732
|
+
const op = (opRaw ?? {});
|
|
733
|
+
const tags = Array.isArray(op.tags) ? op.tags : [];
|
|
734
|
+
rows.push({
|
|
735
|
+
method: method.toUpperCase(),
|
|
736
|
+
path,
|
|
737
|
+
summary: typeof op.summary === 'string' ? op.summary : null,
|
|
738
|
+
tag: typeof tags[0] === 'string' ? tags[0] : null,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
let filtered = rows;
|
|
743
|
+
if (gameRaw)
|
|
744
|
+
filtered = filtered.filter((r) => r.path.startsWith(`/${gameRaw}`));
|
|
745
|
+
if (q) {
|
|
746
|
+
filtered = filtered.filter((r) => r.path.toLowerCase().includes(q) || (r.summary ?? '').toLowerCase().includes(q));
|
|
747
|
+
}
|
|
748
|
+
filtered.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
749
|
+
const notes = [];
|
|
750
|
+
if (gameRaw && filtered.length === 0 && SPEC_OMITS[gameRaw])
|
|
751
|
+
notes.push(SPEC_OMITS[gameRaw]);
|
|
752
|
+
if (!gameRaw)
|
|
753
|
+
notes.push(SPEC_OMITS.lol);
|
|
754
|
+
if (filtered.length > limit) {
|
|
755
|
+
notes.push(`${filtered.length} routes matched; showing ${limit}. Narrow with game or q.`);
|
|
756
|
+
}
|
|
757
|
+
return successEnvelope({
|
|
758
|
+
data: {
|
|
759
|
+
specVersion: typeof spec.info?.version === 'string'
|
|
760
|
+
? spec.info.version
|
|
761
|
+
: null,
|
|
762
|
+
totalDocumented: rows.length,
|
|
763
|
+
matched: filtered.length,
|
|
764
|
+
routes: filtered.slice(0, limit),
|
|
765
|
+
allowlistedPrefixes: ALLOWLIST_PREFIXES,
|
|
766
|
+
notes,
|
|
767
|
+
nextSteps: [
|
|
768
|
+
'Prefer a curated tool when one covers the outcome (list_capabilities)',
|
|
769
|
+
'call_api { path } for a route with no curated equivalent',
|
|
770
|
+
],
|
|
771
|
+
},
|
|
772
|
+
game: gameRaw || null,
|
|
773
|
+
source: 'list_routes',
|
|
774
|
+
requestId,
|
|
775
|
+
tookMs: Date.now() - started,
|
|
776
|
+
upstreamCalls: 1,
|
|
777
|
+
rateLimit: res.headers,
|
|
778
|
+
});
|
|
779
|
+
},
|
|
780
|
+
};
|
|
781
|
+
export const metaTools = [listCapabilities, apiHealth, listRoutes, callApi];
|
|
608
782
|
// silence unused import in case extractRows needed later
|
|
609
783
|
void extractRows;
|
package/dist/tools/normalize.js
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-game row normalization for live/schedule boards and entity cards.
|
|
3
3
|
*/
|
|
4
|
-
import { asRecord, pickString, unwrapPayload } from '../client.js';
|
|
5
|
-
|
|
4
|
+
import { asRecord, pickString, unwrapPayload, DEFAULT_API_BASE } from '../client.js';
|
|
5
|
+
const IMAGE_PROXY_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
6
|
+
/**
|
|
7
|
+
* Bout rows carry raw ufc.com URLs but no proxied variant (only the fighter
|
|
8
|
+
* detail endpoint includes one). ufc.com can hotlink-block and sends no CORS
|
|
9
|
+
* header, so a browser-side card built straight off those URLs shows broken
|
|
10
|
+
* images. The proxy token is base64url of the source URL — verified to
|
|
11
|
+
* round-trip and serve HTTP 200 — so derive it rather than making the caller
|
|
12
|
+
* N+1 the fighter endpoint just to get a loadable image.
|
|
13
|
+
*
|
|
14
|
+
* UFC only: /public/images/lol/<token> returns HTTP 400, so other games get
|
|
15
|
+
* their raw URL and a null proxy rather than a fabricated link.
|
|
16
|
+
*/
|
|
17
|
+
function deriveProxiedImageUrl(rawUrl, game) {
|
|
18
|
+
if (!rawUrl || game !== 'ufc')
|
|
19
|
+
return null;
|
|
20
|
+
let host;
|
|
21
|
+
try {
|
|
22
|
+
host = new URL(rawUrl).hostname.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (host !== 'ufc.com' && !host.endsWith('.ufc.com'))
|
|
28
|
+
return null;
|
|
29
|
+
return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
|
|
30
|
+
}
|
|
31
|
+
function sideFrom(name, id, slug, score, extra) {
|
|
6
32
|
if (!name && !id && !slug)
|
|
7
33
|
return null;
|
|
8
34
|
return {
|
|
@@ -10,9 +36,96 @@ function sideFrom(name, id, slug, score) {
|
|
|
10
36
|
...(id ? { id } : {}),
|
|
11
37
|
...(slug ? { slug } : {}),
|
|
12
38
|
...(score !== undefined ? { score } : {}),
|
|
39
|
+
...(extra ?? {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function numOrNull(v) {
|
|
43
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
44
|
+
return v;
|
|
45
|
+
if (typeof v === 'string' && v !== '' && Number.isFinite(Number(v)))
|
|
46
|
+
return Number(v);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Collect image URLs from a corner/team row and its nested profile. Returns
|
|
51
|
+
* undefined when the upstream carried none, so lean rows stay lean; when any
|
|
52
|
+
* image exists every key is present (null where absent) — a missing key would
|
|
53
|
+
* read as "this API has no images" rather than "no image for this one".
|
|
54
|
+
*/
|
|
55
|
+
function imagesFrom(game, ...sources) {
|
|
56
|
+
const pick = (...keys) => {
|
|
57
|
+
for (const src of sources) {
|
|
58
|
+
if (!src)
|
|
59
|
+
continue;
|
|
60
|
+
for (const k of keys) {
|
|
61
|
+
const v = pickString(src[k]);
|
|
62
|
+
if (v)
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
const images = {
|
|
69
|
+
headshotUrl: pick('headshotUrl', 'headshot'),
|
|
70
|
+
bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
|
|
71
|
+
imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo'),
|
|
72
|
+
proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
|
|
13
73
|
};
|
|
74
|
+
if (!images.proxiedImageUrl) {
|
|
75
|
+
// Prefer the headshot for the proxied variant — it is the crop a card UI wants.
|
|
76
|
+
images.proxiedImageUrl =
|
|
77
|
+
deriveProxiedImageUrl(images.headshotUrl, game) ??
|
|
78
|
+
deriveProxiedImageUrl(images.imageUrl, game);
|
|
79
|
+
}
|
|
80
|
+
return Object.values(images).some(Boolean) ? images : undefined;
|
|
81
|
+
}
|
|
82
|
+
function recordFrom(...sources) {
|
|
83
|
+
for (const src of sources) {
|
|
84
|
+
if (!src)
|
|
85
|
+
continue;
|
|
86
|
+
const rec = asRecord(src.record);
|
|
87
|
+
const text = pickString(src.recordText, rec?.text);
|
|
88
|
+
if (!rec && !text)
|
|
89
|
+
continue;
|
|
90
|
+
const out = {
|
|
91
|
+
wins: numOrNull(rec?.wins),
|
|
92
|
+
losses: numOrNull(rec?.losses),
|
|
93
|
+
draws: numOrNull(rec?.draws),
|
|
94
|
+
noContest: numOrNull(rec?.noContest),
|
|
95
|
+
text: text ?? null,
|
|
96
|
+
};
|
|
97
|
+
if (Object.values(out).some((v) => v !== null))
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
14
101
|
}
|
|
15
|
-
|
|
102
|
+
/** Enrichment shared by UFC corners and (where upstream supplies it) team rows. */
|
|
103
|
+
function sideExtras(o, game) {
|
|
104
|
+
const profile = asRecord(o.profile) ?? undefined;
|
|
105
|
+
const fighter = asRecord(o.fighter) ?? undefined;
|
|
106
|
+
const images = imagesFrom(game, o, profile, fighter);
|
|
107
|
+
const record = recordFrom(o, profile, fighter);
|
|
108
|
+
const nickname = pickString(profile?.nickname, fighter?.nickname, o.nickname);
|
|
109
|
+
const rank = pickString(o.rankText, o.rank, profile?.rankText);
|
|
110
|
+
const championStatus = pickString(o.championStatus, profile?.championStatus);
|
|
111
|
+
const division = pickString(profile?.division, o.division, o.weightClass);
|
|
112
|
+
const country = pickString(o.country, profile?.country);
|
|
113
|
+
const flag = pickString(o.flag, profile?.flag);
|
|
114
|
+
const outcome = pickString(o.outcome);
|
|
115
|
+
return {
|
|
116
|
+
...(nickname ? { nickname } : {}),
|
|
117
|
+
...(images ? { images } : {}),
|
|
118
|
+
...(record ? { record } : {}),
|
|
119
|
+
...(rank ? { rank } : {}),
|
|
120
|
+
// "none" carries no signal for a page; only surface an actual belt state.
|
|
121
|
+
...(championStatus && championStatus.toLowerCase() !== 'none' ? { championStatus } : {}),
|
|
122
|
+
...(division ? { division } : {}),
|
|
123
|
+
...(country ? { country } : {}),
|
|
124
|
+
...(flag ? { flag } : {}),
|
|
125
|
+
...(outcome ? { outcome } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function nestedSide(raw, game) {
|
|
16
129
|
const o = asRecord(raw);
|
|
17
130
|
if (!o) {
|
|
18
131
|
if (typeof raw === 'string')
|
|
@@ -29,7 +142,7 @@ function nestedSide(raw) {
|
|
|
29
142
|
: typeof scoreRaw === 'string' && scoreRaw !== ''
|
|
30
143
|
? Number(scoreRaw)
|
|
31
144
|
: null;
|
|
32
|
-
return sideFrom(name, id, slug, Number.isFinite(score) ? score : null);
|
|
145
|
+
return sideFrom(name, id, slug, Number.isFinite(score) ? score : null, sideExtras(o, game));
|
|
33
146
|
}
|
|
34
147
|
function scoreNum(v) {
|
|
35
148
|
if (typeof v === 'number' && Number.isFinite(v))
|
|
@@ -42,10 +155,22 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
42
155
|
// Always peel { success, data } so UFC bout fighters[] / status are visible.
|
|
43
156
|
const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
|
|
44
157
|
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
|
|
45
|
-
let team1 = nestedSide(r.team1) ??
|
|
158
|
+
let team1 = nestedSide(r.team1, game) ??
|
|
46
159
|
sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
|
|
47
|
-
let team2 = nestedSide(r.team2) ??
|
|
160
|
+
let team2 = nestedSide(r.team2, game) ??
|
|
48
161
|
sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
|
|
162
|
+
// CS2 rows nest team1/team2 objects that lack a score; a nested side must not
|
|
163
|
+
// shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
|
|
164
|
+
if (team1 && team1.score == null) {
|
|
165
|
+
const s = scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore);
|
|
166
|
+
if (s != null)
|
|
167
|
+
team1 = { ...team1, score: s };
|
|
168
|
+
}
|
|
169
|
+
if (team2 && team2.score == null) {
|
|
170
|
+
const s = scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore);
|
|
171
|
+
if (s != null)
|
|
172
|
+
team2 = { ...team2, score: s };
|
|
173
|
+
}
|
|
49
174
|
// UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
|
|
50
175
|
// Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
|
|
51
176
|
// Live tracking uses red/blue + fighters[] (not team1/team2).
|
|
@@ -64,7 +189,7 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
64
189
|
const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
|
|
65
190
|
const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
|
|
66
191
|
const score = scoreNum(o.score ?? o.points);
|
|
67
|
-
return sideFrom(name, id, slug, score);
|
|
192
|
+
return sideFrom(name, id, slug, score, sideExtras(o, game));
|
|
68
193
|
};
|
|
69
194
|
team1 =
|
|
70
195
|
team1 ??
|
|
@@ -94,8 +219,8 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
94
219
|
}
|
|
95
220
|
// COD sometimes uses teams[]
|
|
96
221
|
if ((!team1 || !team2) && Array.isArray(r.teams)) {
|
|
97
|
-
team1 = team1 ?? nestedSide(r.teams[0]);
|
|
98
|
-
team2 = team2 ?? nestedSide(r.teams[1]);
|
|
222
|
+
team1 = team1 ?? nestedSide(r.teams[0], game);
|
|
223
|
+
team2 = team2 ?? nestedSide(r.teams[1], game);
|
|
99
224
|
}
|
|
100
225
|
const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
|
|
101
226
|
const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
|
|
@@ -151,9 +276,12 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
151
276
|
const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
|
|
152
277
|
// Prefer explicit title/label when present, but never keep the placeholder
|
|
153
278
|
// "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
|
|
279
|
+
// When both sides lack names, prefer event / weight / bout id over opaque "? vs ?"
|
|
280
|
+
// so agent boards (live_matches, upcoming_schedule, event_card) stay legible.
|
|
154
281
|
const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
|
|
155
282
|
const explicit = pickString(r.label, r.title, r.name);
|
|
156
283
|
const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
|
|
284
|
+
const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
|
|
157
285
|
let label;
|
|
158
286
|
if (explicit && !explicitIsPlaceholder) {
|
|
159
287
|
label = explicit;
|
|
@@ -162,8 +290,28 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
162
290
|
label = vsLabel;
|
|
163
291
|
}
|
|
164
292
|
else {
|
|
165
|
-
|
|
293
|
+
// Identity fallbacks — never invent fighter names, but avoid bare "? vs ?"
|
|
294
|
+
label =
|
|
295
|
+
pickString(eventName, eventSlug, weightOrClass) ??
|
|
296
|
+
(matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
|
|
297
|
+
vsLabel;
|
|
166
298
|
}
|
|
299
|
+
// Bout metadata already present on the upstream row. Passing it through here
|
|
300
|
+
// is what stops an agent from N+1'ing call_api to rebuild a fight card.
|
|
301
|
+
const cardSection = pickString(r.cardSection, r.cardSegment, r.segment);
|
|
302
|
+
const cardPosition = pickString(r.cardPosition);
|
|
303
|
+
const cardSectionOrder = numOrNull(r.cardSectionOrder);
|
|
304
|
+
const boutOrder = numOrNull(r.boutOrder);
|
|
305
|
+
const hasCard = cardSection != null || cardPosition != null || cardSectionOrder != null || boutOrder != null;
|
|
306
|
+
const method = pickString(r.method);
|
|
307
|
+
const methodDetails = pickString(r.methodDetails);
|
|
308
|
+
const resultTime = pickString(r.resultTime);
|
|
309
|
+
// UFC sends referee as { id, name, firstName, lastName } — not a string.
|
|
310
|
+
const referee = pickString(r.referee, asRecord(r.referee)?.name);
|
|
311
|
+
const winnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
|
|
312
|
+
const resultRound = numOrNull(r.resultRound);
|
|
313
|
+
const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
|
|
314
|
+
const isCancelled = r.isCancelled === true;
|
|
167
315
|
return {
|
|
168
316
|
game,
|
|
169
317
|
matchId,
|
|
@@ -174,8 +322,55 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
174
322
|
team2,
|
|
175
323
|
event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
|
|
176
324
|
league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
|
|
325
|
+
...(weightOrClass ? { weightClass: weightOrClass } : {}),
|
|
326
|
+
...(r.titleBout != null ? { titleBout: Boolean(r.titleBout) } : {}),
|
|
327
|
+
...(hasCard
|
|
328
|
+
? {
|
|
329
|
+
card: {
|
|
330
|
+
section: cardSection ?? null,
|
|
331
|
+
sectionOrder: cardSectionOrder,
|
|
332
|
+
position: cardPosition ?? null,
|
|
333
|
+
order: boutOrder,
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
: {}),
|
|
337
|
+
...(hasResultDetail
|
|
338
|
+
? {
|
|
339
|
+
result: {
|
|
340
|
+
method: method ?? null,
|
|
341
|
+
methodDetails: methodDetails ?? null,
|
|
342
|
+
round: resultRound,
|
|
343
|
+
time: resultTime ?? null,
|
|
344
|
+
referee: referee ?? null,
|
|
345
|
+
winnerSlug: winnerSlug ?? null,
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
349
|
+
...(isCancelled
|
|
350
|
+
? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
|
|
351
|
+
: {}),
|
|
177
352
|
};
|
|
178
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Sort a fight card the way it is presented: main card before prelims, and the
|
|
356
|
+
* main event at the top of its section. Rows without placement keep their
|
|
357
|
+
* upstream order behind those that have it.
|
|
358
|
+
*/
|
|
359
|
+
export function sortByCardOrder(rows) {
|
|
360
|
+
const rank = (x, i) => ({
|
|
361
|
+
section: x.card?.sectionOrder ?? Number.MAX_SAFE_INTEGER,
|
|
362
|
+
order: x.card?.order ?? Number.MAX_SAFE_INTEGER,
|
|
363
|
+
i,
|
|
364
|
+
});
|
|
365
|
+
return rows
|
|
366
|
+
.map((x, i) => ({ x, k: rank(x, i) }))
|
|
367
|
+
.sort((a, b) => a.k.section !== b.k.section
|
|
368
|
+
? a.k.section - b.k.section
|
|
369
|
+
: a.k.order !== b.k.order
|
|
370
|
+
? a.k.order - b.k.order
|
|
371
|
+
: a.k.i - b.k.i)
|
|
372
|
+
.map((e) => e.x);
|
|
373
|
+
}
|
|
179
374
|
export function entityRef(row, type, game) {
|
|
180
375
|
const r = asRecord(row) ?? {};
|
|
181
376
|
const id = pickString(r.id, r.teamId, r.playerId, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
|