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:
parent
87279933c3
commit
09396dafce
1 changed files with 190 additions and 13 deletions
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue