cito-mcp 0.3.4 → 0.3.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 +519 -519
- package/dist/install.js +15 -15
- package/dist/tools/live.js +45 -10
- package/dist/tools/match.js +45 -1
- package/dist/tools/normalize.js +16 -1
- package/dist/tools/resolve.js +43 -32
- package/dist/tools/team.js +88 -3
- package/package.json +1 -1
package/dist/install.js
CHANGED
|
@@ -264,21 +264,21 @@ function parseArgs(argv) {
|
|
|
264
264
|
}
|
|
265
265
|
return out;
|
|
266
266
|
}
|
|
267
|
-
const HELP = `cito-mcp install — configure the Cito MCP server for your editors
|
|
268
|
-
|
|
269
|
-
Usage:
|
|
270
|
-
npx cito-mcp install --key cito_xxx
|
|
271
|
-
npx cito-mcp install --key cito_xxx --client cursor,claude-code
|
|
272
|
-
npx cito-mcp install --dry-run
|
|
273
|
-
|
|
274
|
-
Options:
|
|
275
|
-
--key <key> Cito API key. Falls back to $CITO_API_KEY.
|
|
276
|
-
--client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
|
|
277
|
-
Default: every client detected on this machine.
|
|
278
|
-
--base <url> Override API base (staging / self-hosted).
|
|
279
|
-
--dry-run, -n Show what would change; write nothing.
|
|
280
|
-
--help, -h This message.
|
|
281
|
-
|
|
267
|
+
const HELP = `cito-mcp install — configure the Cito MCP server for your editors
|
|
268
|
+
|
|
269
|
+
Usage:
|
|
270
|
+
npx cito-mcp install --key cito_xxx
|
|
271
|
+
npx cito-mcp install --key cito_xxx --client cursor,claude-code
|
|
272
|
+
npx cito-mcp install --dry-run
|
|
273
|
+
|
|
274
|
+
Options:
|
|
275
|
+
--key <key> Cito API key. Falls back to $CITO_API_KEY.
|
|
276
|
+
--client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
|
|
277
|
+
Default: every client detected on this machine.
|
|
278
|
+
--base <url> Override API base (staging / self-hosted).
|
|
279
|
+
--dry-run, -n Show what would change; write nothing.
|
|
280
|
+
--help, -h This message.
|
|
281
|
+
|
|
282
282
|
Get a key at https://citoapi.com/dashboard`;
|
|
283
283
|
export async function runInstall(argv) {
|
|
284
284
|
const args = parseArgs(argv);
|
package/dist/tools/live.js
CHANGED
|
@@ -422,13 +422,25 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
422
422
|
warnings.push(`Ignored filter "${param}": ${reason}`);
|
|
423
423
|
};
|
|
424
424
|
let path = '';
|
|
425
|
+
// Set when this tool, not the API, is responsible for the team filter. It
|
|
426
|
+
// also moves paging client-side, because an upstream offset would be
|
|
427
|
+
// counted against rows we are about to discard.
|
|
428
|
+
let clientTeamFilter = false;
|
|
425
429
|
let query = { limit, offset };
|
|
426
430
|
if (game === 'lol') {
|
|
427
431
|
path = '/lol/schedule/upcoming';
|
|
432
|
+
// The upstream accepts teamSlug but does not apply it: asking for t1
|
|
433
|
+
// returned Dplus KIA, Team WE and NightBirds. That is a silently wrong
|
|
434
|
+
// answer, which is worse than an error, so the filter is enforced below.
|
|
435
|
+
// Filtering client-side means the upstream offset would be counted
|
|
436
|
+
// against unfiltered rows, so when a team is named we over-fetch and page
|
|
437
|
+
// here instead.
|
|
438
|
+
if (team)
|
|
439
|
+
clientTeamFilter = true;
|
|
428
440
|
query = {
|
|
429
441
|
hours: String(hours),
|
|
430
|
-
limit: String(limit),
|
|
431
|
-
offset: String(offset),
|
|
442
|
+
limit: String(clientTeamFilter ? Math.min(200, (offset + limit) * 4) : limit),
|
|
443
|
+
...(clientTeamFilter ? {} : { offset: String(offset) }),
|
|
432
444
|
...(league ? { leagueSlug: league } : {}),
|
|
433
445
|
...(team ? { teamSlug: team } : {}),
|
|
434
446
|
};
|
|
@@ -514,12 +526,7 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
514
526
|
noteIgnored('tournamentId', 'tennis fixtures have no tournamentId filter');
|
|
515
527
|
if (team)
|
|
516
528
|
noteIgnored('team', 'tennis fixtures have no team filter; match player names client-side');
|
|
517
|
-
|
|
518
|
-
noteIgnored('hours', 'tennis fixtures cover ~3 days; filter by startTime client-side');
|
|
519
|
-
if (from)
|
|
520
|
-
noteIgnored('from', 'tennis fixtures cover ~3 days; filter by startTime client-side');
|
|
521
|
-
if (to)
|
|
522
|
-
noteIgnored('to', 'tennis fixtures cover ~3 days; filter by startTime client-side');
|
|
529
|
+
// hours / from / to applied client-side after fetch (below) — not warned as ignored.
|
|
523
530
|
}
|
|
524
531
|
const res = await fetchJson(ctx, path, { query });
|
|
525
532
|
if (!res.ok) {
|
|
@@ -600,8 +607,32 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
600
607
|
}
|
|
601
608
|
}
|
|
602
609
|
}
|
|
610
|
+
// Tennis: client-side hours / from / to window (fixtures list has no time params)
|
|
611
|
+
if (game === 'tennis' && (args.hours !== undefined || from || to)) {
|
|
612
|
+
const now = Date.now();
|
|
613
|
+
const fromMs = from ? Date.parse(from) : now;
|
|
614
|
+
const toMs = to ? Date.parse(to) : now + hours * 3600_000;
|
|
615
|
+
if (Number.isFinite(fromMs) && Number.isFinite(toMs)) {
|
|
616
|
+
const before = rows.length;
|
|
617
|
+
rows = rows.filter((row) => {
|
|
618
|
+
const r = asRecord(row) ?? {};
|
|
619
|
+
const ts = pickString(r.startTime, r.start_time, r.startsAt);
|
|
620
|
+
if (!ts)
|
|
621
|
+
return true; // keep undated rows rather than drop them
|
|
622
|
+
const t = Date.parse(ts);
|
|
623
|
+
if (!Number.isFinite(t))
|
|
624
|
+
return true;
|
|
625
|
+
return t >= fromMs && t <= toMs;
|
|
626
|
+
});
|
|
627
|
+
if (before > 0 && rows.length === 0) {
|
|
628
|
+
warnings.push(`hours/from/to window matched 0 of ${before} tennis fixtures — widen hours or omit time filters`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
603
632
|
// Client-side team filter when API ignored it
|
|
604
|
-
|
|
633
|
+
let beforeTeamFilter = rows.length;
|
|
634
|
+
if (team && (game === 'dota2' || game === 'cs2' || clientTeamFilter)) {
|
|
635
|
+
beforeTeamFilter = rows.length;
|
|
605
636
|
const t = team.toLowerCase();
|
|
606
637
|
rows = rows.filter((row) => {
|
|
607
638
|
const m = normalizeMatch(game, row, 'upcoming');
|
|
@@ -612,7 +643,11 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
|
|
612
643
|
return hay.includes(t);
|
|
613
644
|
});
|
|
614
645
|
}
|
|
615
|
-
|
|
646
|
+
if (team && beforeTeamFilter > 0 && rows.length === 0) {
|
|
647
|
+
warnings.push(`team "${team}" matched 0 of ${beforeTeamFilter} upcoming fixtures in this window — widen hours or check the slug`);
|
|
648
|
+
}
|
|
649
|
+
const items = (clientTeamFilter ? rows.slice(offset, offset + limit) : rows.slice(0, limit))
|
|
650
|
+
.map((row) => normalizeMatch(game, row, 'upcoming'));
|
|
616
651
|
const obj = asRecord(res.data);
|
|
617
652
|
const total = typeof obj?.total === 'number' ? obj.total : null;
|
|
618
653
|
const hasMore = (typeof obj?.hasMore === 'boolean' ? obj.hasMore : items.length >= limit) ||
|
package/dist/tools/match.js
CHANGED
|
@@ -8,6 +8,50 @@ import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from
|
|
|
8
8
|
async function getSection(ctx, path, query) {
|
|
9
9
|
return fetchJson(ctx, path, { query });
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Series length. The LoL feed states it as `strategy: "Bo5"`, which nothing was
|
|
13
|
+
* reading, so every match reported bestOf: null. Accept the spelled forms and
|
|
14
|
+
* the object shape other titles use, and fall back to the declared game count.
|
|
15
|
+
* Only 1/3/5/7/9 are real series lengths, so anything else is refused rather
|
|
16
|
+
* than guessed.
|
|
17
|
+
*/
|
|
18
|
+
export function parseBestOf(r) {
|
|
19
|
+
const fromNumber = (v) => {
|
|
20
|
+
const n = Number(v);
|
|
21
|
+
return Number.isFinite(n) && [1, 3, 5, 7, 9].includes(n) ? n : null;
|
|
22
|
+
};
|
|
23
|
+
const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo);
|
|
24
|
+
if (direct)
|
|
25
|
+
return direct;
|
|
26
|
+
const strat = r.strategy;
|
|
27
|
+
if (typeof strat === 'string') {
|
|
28
|
+
const m = strat.match(/(\d+)/);
|
|
29
|
+
if (m) {
|
|
30
|
+
const n = fromNumber(m[1]);
|
|
31
|
+
if (n)
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// { type: "bestOf", count: 5 }
|
|
36
|
+
if (strat && typeof strat === 'object') {
|
|
37
|
+
const o = strat;
|
|
38
|
+
const n = fromNumber(o.count) ?? fromNumber(o.value) ?? fromNumber(o.bestOf);
|
|
39
|
+
if (n)
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
for (const key of ['format', 'seriesType', 'matchFormat']) {
|
|
43
|
+
const v = r[key];
|
|
44
|
+
if (typeof v === 'string') {
|
|
45
|
+
const m = v.match(/(\d+)/);
|
|
46
|
+
if (m) {
|
|
47
|
+
const n = fromNumber(m[1]);
|
|
48
|
+
if (n)
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return fromNumber(r.gameCount) ?? null;
|
|
54
|
+
}
|
|
11
55
|
function matchCore(game, matchId, raw) {
|
|
12
56
|
const entity = unwrapPayload(raw);
|
|
13
57
|
const m = normalizeMatch(game, entity);
|
|
@@ -23,7 +67,7 @@ function matchCore(game, matchId, raw) {
|
|
|
23
67
|
status: m.status,
|
|
24
68
|
startTime: m.startTime,
|
|
25
69
|
endTime: pickString(r.endTime, r.endedAt, r.finishedAt) ?? null,
|
|
26
|
-
bestOf: r
|
|
70
|
+
bestOf: parseBestOf(r),
|
|
27
71
|
label: m.label,
|
|
28
72
|
scoreline,
|
|
29
73
|
score: {
|
package/dist/tools/normalize.js
CHANGED
|
@@ -260,8 +260,23 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
260
260
|
r.resultRound != null ||
|
|
261
261
|
r.isComplete === true ||
|
|
262
262
|
r.completed === true;
|
|
263
|
-
|
|
263
|
+
// "started" as a bare substring also matches "not_started", which is how a
|
|
264
|
+
// fixture that has not begun was being served to customers as live. Require
|
|
265
|
+
// word boundaries, refuse the explicit not-started spellings, and refuse any
|
|
266
|
+
// match whose kickoff is still in the future: a scheduled match cannot be
|
|
267
|
+
// in progress no matter what the upstream status string says.
|
|
268
|
+
const unstarted = /not[_\s-]?started|unstarted|not[_\s-]?begun|yet[_\s-]?to[_\s-]?start/.test(statusRaw);
|
|
269
|
+
const looksLive = !unstarted && /\b(live|running|in[_\s-]?progress|ongoing|started|watching)\b/.test(statusRaw);
|
|
270
|
+
const kickoffPassed = (() => {
|
|
271
|
+
if (!startTime)
|
|
272
|
+
return true; // no clock to contradict the feed
|
|
273
|
+
const t = Date.parse(startTime);
|
|
274
|
+
return !Number.isFinite(t) || t <= Date.now();
|
|
275
|
+
})();
|
|
276
|
+
if (looksLive && kickoffPassed)
|
|
264
277
|
status = 'live';
|
|
278
|
+
else if (looksLive && !kickoffPassed)
|
|
279
|
+
status = 'upcoming';
|
|
265
280
|
else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
|
|
266
281
|
status = 'completed';
|
|
267
282
|
}
|
package/dist/tools/resolve.js
CHANGED
|
@@ -367,20 +367,20 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
367
367
|
}
|
|
368
368
|
export const resolveEntity = {
|
|
369
369
|
name: 'resolve_entity',
|
|
370
|
-
description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
|
|
371
|
-
|
|
372
|
-
When to use:
|
|
373
|
-
- User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
|
|
374
|
-
- Need a canonical id/slug before profile or match tools
|
|
375
|
-
|
|
376
|
-
Prefer over search_entities when you want one best match (or small ranked set) to chain.
|
|
377
|
-
Prefer search_entities when browsing many results with pagination.
|
|
378
|
-
|
|
379
|
-
Do not use when: you already have a stable id/slug from a prior tool.
|
|
380
|
-
|
|
381
|
-
Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
|
|
382
|
-
|
|
383
|
-
Parallel-safe: yes. Upstream cost: 1–5.
|
|
370
|
+
description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
|
|
371
|
+
|
|
372
|
+
When to use:
|
|
373
|
+
- User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
|
|
374
|
+
- Need a canonical id/slug before profile or match tools
|
|
375
|
+
|
|
376
|
+
Prefer over search_entities when you want one best match (or small ranked set) to chain.
|
|
377
|
+
Prefer search_entities when browsing many results with pagination.
|
|
378
|
+
|
|
379
|
+
Do not use when: you already have a stable id/slug from a prior tool.
|
|
380
|
+
|
|
381
|
+
Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
|
|
382
|
+
|
|
383
|
+
Parallel-safe: yes. Upstream cost: 1–5.
|
|
384
384
|
Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
|
|
385
385
|
inputSchema: {
|
|
386
386
|
type: 'object',
|
|
@@ -474,22 +474,22 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
|
|
|
474
474
|
};
|
|
475
475
|
export const searchEntities = {
|
|
476
476
|
name: 'search_entities',
|
|
477
|
-
description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
|
|
478
|
-
|
|
479
|
-
When to use:
|
|
480
|
-
- Typeahead / pickers
|
|
481
|
-
- "List teams matching…"
|
|
482
|
-
- Exploring entities without committing to one ID
|
|
483
|
-
- UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
|
|
484
|
-
|
|
485
|
-
Prefer over resolve_entity when the user wants a list.
|
|
486
|
-
Prefer resolve_entity when chaining one name into a profile tool.
|
|
487
|
-
|
|
488
|
-
Do not use when: fetching a known entity profile — use team_profile or player_profile.
|
|
489
|
-
|
|
490
|
-
UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
|
|
491
|
-
|
|
492
|
-
Parallel-safe: yes. Upstream cost: 1–3.
|
|
477
|
+
description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
|
|
478
|
+
|
|
479
|
+
When to use:
|
|
480
|
+
- Typeahead / pickers
|
|
481
|
+
- "List teams matching…"
|
|
482
|
+
- Exploring entities without committing to one ID
|
|
483
|
+
- UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
|
|
484
|
+
|
|
485
|
+
Prefer over resolve_entity when the user wants a list.
|
|
486
|
+
Prefer resolve_entity when chaining one name into a profile tool.
|
|
487
|
+
|
|
488
|
+
Do not use when: fetching a known entity profile — use team_profile or player_profile.
|
|
489
|
+
|
|
490
|
+
UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
|
|
491
|
+
|
|
492
|
+
Parallel-safe: yes. Upstream cost: 1–3.
|
|
493
493
|
Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
494
494
|
inputSchema: {
|
|
495
495
|
type: 'object',
|
|
@@ -533,6 +533,11 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
533
533
|
let items = [];
|
|
534
534
|
let upstreamCalls = 0;
|
|
535
535
|
let total = null;
|
|
536
|
+
// pageQuery sends offset/page upstream, so what comes back IS the page and
|
|
537
|
+
// must not be sliced by offset again. Doing both returned an EMPTY page two
|
|
538
|
+
// while reporting total=1429 and hasMore=true. The UFC paths below fetch
|
|
539
|
+
// page 1 deliberately and re-rank in memory, so they page here instead.
|
|
540
|
+
let upstreamPaged = true;
|
|
536
541
|
const pageQuery = (extra = {}) => ({
|
|
537
542
|
limit,
|
|
538
543
|
offset,
|
|
@@ -649,6 +654,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
649
654
|
}
|
|
650
655
|
else if (game === 'cod') {
|
|
651
656
|
if (q) {
|
|
657
|
+
// No offset goes upstream on this path, so page it here.
|
|
658
|
+
upstreamPaged = false;
|
|
652
659
|
const res = await fetchJson(ctx, '/cod/search', {
|
|
653
660
|
query: { q, limit, ...(type !== 'any' ? { type: type === 'team' ? 'org' : type } : {}) },
|
|
654
661
|
});
|
|
@@ -668,6 +675,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
668
675
|
else if (game === 'ufc') {
|
|
669
676
|
if (q) {
|
|
670
677
|
// Dedicated search ranks name/slug/nickname; /fighters ignores q.
|
|
678
|
+
// Returns the whole match set for client re-ranking; page it here.
|
|
679
|
+
upstreamPaged = false;
|
|
671
680
|
const res = await fetchJson(ctx, '/ufc/search', { query: { q } });
|
|
672
681
|
upstreamCalls += 1;
|
|
673
682
|
if (!res.ok) {
|
|
@@ -762,8 +771,10 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
762
771
|
});
|
|
763
772
|
}
|
|
764
773
|
// pagination over ranked result set
|
|
765
|
-
const pageItems = items.slice(offset, offset + limit);
|
|
766
|
-
const hasMore =
|
|
774
|
+
const pageItems = upstreamPaged ? items.slice(0, limit) : items.slice(offset, offset + limit);
|
|
775
|
+
const hasMore = upstreamPaged
|
|
776
|
+
? (total != null ? offset + pageItems.length < total : pageItems.length >= limit)
|
|
777
|
+
: offset + pageItems.length < items.length;
|
|
767
778
|
return successEnvelope({
|
|
768
779
|
source: 'search_entities',
|
|
769
780
|
game,
|
package/dist/tools/team.js
CHANGED
|
@@ -285,20 +285,32 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
|
|
|
285
285
|
}
|
|
286
286
|
})());
|
|
287
287
|
tasks.push((async () => {
|
|
288
|
-
|
|
289
|
-
|
|
288
|
+
// The matches index, not /lol/schedule. Schedule returned the team's
|
|
289
|
+
// oldest fixtures: a T1 profile showed MSI 2023 as "recent" while the
|
|
290
|
+
// index had the 2026 LCK split, newest first. Sort here anyway rather
|
|
291
|
+
// than trusting upstream order, so the contract holds if either
|
|
292
|
+
// endpoint changes.
|
|
293
|
+
const slug = team.slug || idOrSlug;
|
|
294
|
+
const res = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(slug)}/matches`, {
|
|
295
|
+
query: { limit: String(recentLimit) },
|
|
290
296
|
});
|
|
291
297
|
upstreamCalls += 1;
|
|
292
298
|
rateLimit = { ...rateLimit, ...res.headers };
|
|
293
299
|
if (res.ok) {
|
|
300
|
+
const startMs = (row) => {
|
|
301
|
+
const t = Date.parse(String(row?.startTime ?? ''));
|
|
302
|
+
return Number.isFinite(t) ? t : -Infinity;
|
|
303
|
+
};
|
|
294
304
|
recentMatches = extractRows(res.data)
|
|
305
|
+
.slice()
|
|
306
|
+
.sort((a, b) => startMs(b) - startMs(a))
|
|
295
307
|
.slice(0, recentLimit)
|
|
296
308
|
.map((row) => normalizeMatch('lol', row, 'completed'));
|
|
297
309
|
}
|
|
298
310
|
else {
|
|
299
311
|
partial.push(partialFromRejection('recentMatches', {
|
|
300
312
|
code: mapHttpToCode(res.status),
|
|
301
|
-
message: `
|
|
313
|
+
message: `team matches HTTP ${res.status}`,
|
|
302
314
|
httpStatus: res.status,
|
|
303
315
|
}));
|
|
304
316
|
}
|
|
@@ -664,6 +676,68 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
664
676
|
rows = extractRows(res.data);
|
|
665
677
|
}
|
|
666
678
|
else if (game === 'lol') {
|
|
679
|
+
// First-class H2H. The old path pulled sideA's /lol/schedule and filtered
|
|
680
|
+
// client-side, which missed meetings and returned the oldest first, so a
|
|
681
|
+
// T1 vs HLE rivalry showed 2023 games and called the earliest one the
|
|
682
|
+
// "last meeting". This endpoint states the record outright (22 meetings,
|
|
683
|
+
// 8-13) and lists them newest first.
|
|
684
|
+
const h2hRes = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(sideA)}/h2h/${encodeURIComponent(sideB)}`, { query: { limit: String(limit) } });
|
|
685
|
+
upstreamCalls += 1;
|
|
686
|
+
rateLimit = { ...rateLimit, ...h2hRes.headers };
|
|
687
|
+
if (h2hRes.ok) {
|
|
688
|
+
const env = asRecord(h2hRes.data) ?? {};
|
|
689
|
+
const h2h = asRecord(env.data) ?? env;
|
|
690
|
+
const agg = asRecord(h2h.matches) ?? {};
|
|
691
|
+
const opponent = asRecord(h2h.opponent) ?? {};
|
|
692
|
+
const startMs = (v) => {
|
|
693
|
+
const t = Date.parse(String(v ?? ''));
|
|
694
|
+
return Number.isFinite(t) ? t : -Infinity;
|
|
695
|
+
};
|
|
696
|
+
const meetingRows = extractRows(h2h.recentMatches)
|
|
697
|
+
.slice()
|
|
698
|
+
.sort((a, b) => startMs(asRecord(b)?.date) - startMs(asRecord(a)?.date))
|
|
699
|
+
.slice(0, limit)
|
|
700
|
+
.map((row) => {
|
|
701
|
+
const r = asRecord(row) ?? {};
|
|
702
|
+
return {
|
|
703
|
+
matchId: pickString(r.matchId) ?? null,
|
|
704
|
+
startTime: pickString(r.date) ?? null,
|
|
705
|
+
tournamentName: pickString(r.tournamentName) ?? null,
|
|
706
|
+
// The feed gives scores in the match's own team order, which it
|
|
707
|
+
// does not label, so they are reported as-is rather than being
|
|
708
|
+
// assigned to a side we would be guessing at.
|
|
709
|
+
team1Score: typeof r.team1Score === 'number' ? r.team1Score : null,
|
|
710
|
+
team2Score: typeof r.team2Score === 'number' ? r.team2Score : null,
|
|
711
|
+
winnerSlug: pickString(r.winner) ?? null,
|
|
712
|
+
};
|
|
713
|
+
});
|
|
714
|
+
const wins = typeof agg.wins === 'number' ? agg.wins : null;
|
|
715
|
+
const losses = typeof agg.losses === 'number' ? agg.losses : null;
|
|
716
|
+
const total = typeof agg.total === 'number' ? agg.total : meetingRows.length;
|
|
717
|
+
return successEnvelope({
|
|
718
|
+
source: 'head_to_head',
|
|
719
|
+
game,
|
|
720
|
+
requestId,
|
|
721
|
+
tookMs: Date.now() - started,
|
|
722
|
+
upstreamCalls,
|
|
723
|
+
rateLimit,
|
|
724
|
+
data: {
|
|
725
|
+
sideA: { idOrSlug: sideA },
|
|
726
|
+
sideB: { idOrSlug: sideB, name: pickString(opponent.name) ?? null },
|
|
727
|
+
record: {
|
|
728
|
+
winsA: wins,
|
|
729
|
+
winsB: losses,
|
|
730
|
+
draws: 0,
|
|
731
|
+
meetings: total,
|
|
732
|
+
},
|
|
733
|
+
games: asRecord(h2h.games) ?? null,
|
|
734
|
+
meetings: meetingRows,
|
|
735
|
+
lastMeeting: meetingRows[0] ?? null,
|
|
736
|
+
notes: ['Record and meetings come from the first-class /lol/teams/{slug}/h2h/{opponent} endpoint'],
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
warnings.push(`first-class LoL H2H unavailable (HTTP ${h2hRes.status}); falling back to schedule filtering`);
|
|
667
741
|
const res = await fetchJson(ctx, '/lol/schedule', {
|
|
668
742
|
query: { teamSlug: sideA, completed: 'true', limit: '50' },
|
|
669
743
|
});
|
|
@@ -832,6 +906,17 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
832
906
|
if (!Number.isNaN(toMs) && t > toMs)
|
|
833
907
|
return false;
|
|
834
908
|
return true;
|
|
909
|
+
})
|
|
910
|
+
// Newest first. Nothing sorted this before, so `lastMeeting` below was
|
|
911
|
+
// whichever row the upstream happened to return first, which on an
|
|
912
|
+
// ascending feed is the OLDEST meeting. It also means a limit cut off the
|
|
913
|
+
// recent games rather than the ancient ones.
|
|
914
|
+
.sort((a, b) => {
|
|
915
|
+
const ta = a.startTime ? Date.parse(a.startTime) : NaN;
|
|
916
|
+
const tb = b.startTime ? Date.parse(b.startTime) : NaN;
|
|
917
|
+
const va = Number.isFinite(ta) ? ta : -Infinity;
|
|
918
|
+
const vb = Number.isFinite(tb) ? tb : -Infinity;
|
|
919
|
+
return vb - va;
|
|
835
920
|
})
|
|
836
921
|
.slice(0, limit);
|
|
837
922
|
let winsA = 0;
|
package/package.json
CHANGED