feat(lolstats): add Riot API hybrid — show ALL game modes (URF, Brawl, etc.)

op.gg REST API doesn't track featured game modes (URF, ARAM Mayhem/Brawl).
Now uses Riot API for match history when RIOT_API_KEY env var is set,
falling back to op.gg REST for profile/ranked stats (no key needed).

- Add Riot API match fetcher with region routing (europe/americas/asia/sea)
- Add DDragon champion ID→name mapping for Riot API matches
- Add queue ID→name mapping (420=Ranked, 450=ARAM, 900=URF, etc.)
- Transform Riot match data to existing MatchEntry interface
- Batch match detail requests (5 at a time) for rate limit safety
- Keep op.gg REST as fallback when no API key is configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 21:54:53 +01:00
parent 87279933c3
commit 09396dafce

View file

@ -1,8 +1,8 @@
/**
* op.gg API Client uses op.gg REST API as primary data source.
* Falls back to MCP (JSON-RPC 2.0) if REST fails.
* Also handles summoner renewal (data refresh from Riot servers).
* No API key needed.
* Hybrid LoL Stats API Client:
* 1. Riot API for match history (if RIOT_API_KEY set) shows ALL game modes
* 2. op.gg REST API for profile/ranked stats + renewal no key needed
* 3. MCP fallback for edge cases
*/
import type {
@ -14,6 +14,27 @@ const OPGG_API = 'https://lol-api-summoner.op.gg/api';
const MCP_URL = 'https://mcp-api.op.gg/mcp';
let rpcId = 1;
// ── Riot API Config ──
const RIOT_API_KEY = process.env.RIOT_API_KEY ?? '';
/** Map our region codes to Riot regional routing (for Account-V1, Match-V5) */
const REGION_TO_ROUTING: Record<string, string> = {
EUW: 'europe', EUNE: 'europe', TR: 'europe', RU: 'europe',
NA: 'americas', BR: 'americas', LAN: 'americas', LAS: 'americas',
KR: 'asia', JP: 'asia',
OCE: 'sea', PH: 'sea', SG: 'sea', TH: 'sea', TW: 'sea', VN: 'sea',
};
/** Map queue IDs to human-readable names */
const QUEUE_NAMES: Record<number, string> = {
420: 'SOLORANKED', 440: 'FLEXRANKED', 400: 'NORMAL', 430: 'NORMAL',
450: 'ARAM', 900: 'URF', 1020: 'ONE_FOR_ALL', 1300: 'NEXUS_BLITZ',
1400: 'ULTIMATE_SPELLBOOK', 1700: 'ARENA', 1900: 'URF',
720: 'ARAM', // ARAM Clash
0: 'CUSTOM',
};
// ── Regions ──
export const REGIONS: RegionInfo[] = [
@ -177,21 +198,179 @@ export async function getProfile(
};
}
// ── Match History via REST API ──
// ── Match History — Riot API (primary) or op.gg REST (fallback) ──
export async function getMatches(
gameName: string, tagLine: string, region: string, limit = 10,
): Promise<MatchEntry[]> {
await loadChampionMap();
const regionLower = region.toLowerCase();
// Get summoner_id
// Prefer Riot API if key is available — shows ALL game modes (URF, Brawl, etc.)
if (RIOT_API_KEY) {
try {
return await getMatchesRiot(gameName, tagLine, region, limit);
} catch (e: any) {
console.warn(`[LoLStats] Riot API failed (${e.message}), falling back to op.gg`);
}
}
// Fallback: op.gg REST API (only standard modes)
return getMatchesOpgg(gameName, tagLine, region, limit);
}
// ── Riot API Match History ──
async function riotFetch(url: string): Promise<any> {
const res = await fetch(url, {
headers: { 'X-Riot-Token': RIOT_API_KEY },
});
if (res.status === 403) throw new Error('Riot API key expired or invalid');
if (res.status === 429) throw new Error('Riot API rate limited');
if (!res.ok) throw new Error(`Riot API HTTP ${res.status}`);
return res.json();
}
async function getMatchesRiot(
gameName: string, tagLine: string, region: string, limit = 10,
): Promise<MatchEntry[]> {
const routing = REGION_TO_ROUTING[region.toUpperCase()] ?? 'europe';
const baseUrl = `https://${routing}.api.riotgames.com`;
console.log(`[LoLStats] Fetching matches via Riot API (${routing})`);
// Step 1: Get PUUID
const account = await riotFetch(
`${baseUrl}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}`,
);
const puuid = account.puuid;
if (!puuid) throw new Error('PUUID not found');
// Step 2: Get match IDs
const matchIds: string[] = await riotFetch(
`${baseUrl}/lol/match/v5/matches/by-puuid/${puuid}/ids?start=0&count=${Math.min(limit, 20)}`,
);
if (!matchIds.length) {
console.log('[LoLStats] No match IDs from Riot API');
return [];
}
console.log(`[LoLStats] Got ${matchIds.length} match IDs, fetching details...`);
// Step 3: Fetch match details in parallel (batches of 5 to respect rate limits)
const results: MatchEntry[] = [];
const batchSize = 5;
for (let i = 0; i < matchIds.length; i += batchSize) {
const batch = matchIds.slice(i, i + batchSize);
const details = await Promise.all(
batch.map(id =>
riotFetch(`${baseUrl}/lol/match/v5/matches/${id}`)
.catch(e => { console.warn(`[LoLStats] Failed to fetch ${id}:`, e.message); return null; }),
),
);
for (const d of details) {
if (d?.info) results.push(transformRiotMatch(d));
}
}
console.log(`[LoLStats] Processed ${results.length} matches from Riot API`);
return results;
}
function transformRiotMatch(data: any): MatchEntry {
const info = data.info;
const matchId = data.metadata?.matchId ?? '';
const participants: MatchParticipant[] = (info.participants ?? []).map((p: any) => ({
champion_name: p.championName ?? champName(p.championId ?? 0),
champion_id: p.championId ?? 0,
items: [p.item0, p.item1, p.item2, p.item3, p.item4, p.item5, p.item6].filter((x: any) => x != null),
items_names: [],
position: p.teamPosition ?? p.individualPosition ?? '',
team_key: p.teamId === 100 ? 'BLUE' : 'RED',
spells: [p.summoner1Id, p.summoner2Id].filter((x: any) => x != null),
stats: {
kill: p.kills ?? 0,
death: p.deaths ?? 0,
assist: p.assists ?? 0,
minion_kill: p.totalMinionsKilled ?? 0,
neutral_minion_kill: p.neutralMinionsKilled ?? 0,
gold_earned: p.goldEarned ?? 0,
total_damage_dealt_to_champions: p.totalDamageDealtToChampions ?? 0,
total_damage_taken: p.totalDamageTaken ?? 0,
ward_place: p.wardsPlaced ?? 0,
vision_wards_bought_in_game: p.visionWardsBoughtInGame ?? 0,
champion_level: p.champLevel ?? 0,
op_score: 0,
op_score_rank: 0,
result: p.win ? 'WIN' : 'LOSE',
},
summoner: {
game_name: p.riotIdGameName ?? p.summonerName ?? '',
tagline: p.riotIdTagline ?? '',
},
}));
// Build team stats from participant data
const teamKills: Record<string, number> = { BLUE: 0, RED: 0 };
const teamGold: Record<string, number> = { BLUE: 0, RED: 0 };
const teamWin: Record<string, boolean> = { BLUE: false, RED: false };
for (const p of participants) {
const tk = p.team_key;
teamKills[tk] = (teamKills[tk] ?? 0) + p.stats.kill;
teamGold[tk] = (teamGold[tk] ?? 0) + p.stats.gold_earned;
if (p.stats.result === 'WIN') teamWin[tk] = true;
}
const riotTeams = info.teams ?? [];
const teams: TeamStat[] = ['BLUE', 'RED'].map(key => {
const rTeam = riotTeams.find((t: any) => (t.teamId === 100 ? 'BLUE' : 'RED') === key);
return {
key,
banned_champions_names: (rTeam?.bans ?? []).map((b: any) => champName(b.championId ?? 0)),
game_stat: {
is_win: teamWin[key] ?? false,
baron_kill: rTeam?.objectives?.baron?.kills ?? 0,
dragon_kill: rTeam?.objectives?.dragon?.kills ?? 0,
tower_kill: rTeam?.objectives?.tower?.kills ?? 0,
champion_kill: teamKills[key] ?? 0,
gold_earned: teamGold[key] ?? 0,
inhibitor_kill: rTeam?.objectives?.inhibitor?.kills ?? 0,
rift_herald_kill: rTeam?.objectives?.riftHerald?.kills ?? 0,
},
};
});
// Determine game type from queue ID
const queueId = info.queueId ?? 0;
const gameType = QUEUE_NAMES[queueId] ?? info.gameMode ?? 'UNKNOWN';
return {
id: matchId,
created_at: new Date(info.gameCreation ?? 0).toISOString(),
game_length_second: info.gameDuration ?? 0,
game_type: gameType,
game_map: info.mapId === 12 ? 'HOWLING_ABYSS' : info.mapId === 11 ? 'SUMMONERS_RIFT' : `MAP_${info.mapId}`,
average_tier_info: undefined,
participants,
teams,
};
}
// ── op.gg REST API Match History ──
async function getMatchesOpgg(
gameName: string, tagLine: string, region: string, limit = 10,
): Promise<MatchEntry[]> {
const regionLower = region.toLowerCase();
const summoner = await lookupSummoner(gameName, tagLine, region);
// Fetch games — NOTE: uses /api/ (no v3!) for games endpoint
const gamesUrl = `${OPGG_API}/${regionLower}/summoners/${encodeURIComponent(summoner.summoner_id)}/games?limit=${Math.min(limit, 20)}&game_type=total&hl=en_US`;
console.log(`[LoLStats] Fetching matches via REST API`);
console.log(`[LoLStats] Fetching matches via op.gg REST API`);
const res = await fetch(gamesUrl, { headers: { 'Accept': 'application/json' } });
@ -204,13 +383,11 @@ export async function getMatches(
const games = json?.data;
if (!Array.isArray(games) || games.length === 0) {
console.log('[LoLStats] No games from REST API');
console.log('[LoLStats] No games from op.gg REST API');
return [];
}
console.log(`[LoLStats] Got ${games.length} games from REST API`);
// Transform REST API games to our MatchEntry format
console.log(`[LoLStats] Got ${games.length} games from op.gg REST API`);
return games.map((game: any) => transformGame(game));
}