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.
|
* Hybrid LoL Stats API Client:
|
||||||
* Falls back to MCP (JSON-RPC 2.0) if REST fails.
|
* 1. Riot API for match history (if RIOT_API_KEY set) — shows ALL game modes
|
||||||
* Also handles summoner renewal (data refresh from Riot servers).
|
* 2. op.gg REST API for profile/ranked stats + renewal — no key needed
|
||||||
* No API key needed.
|
* 3. MCP fallback for edge cases
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
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';
|
const MCP_URL = 'https://mcp-api.op.gg/mcp';
|
||||||
let rpcId = 1;
|
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 ──
|
// ── Regions ──
|
||||||
|
|
||||||
export const REGIONS: RegionInfo[] = [
|
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(
|
export async function getMatches(
|
||||||
gameName: string, tagLine: string, region: string, limit = 10,
|
gameName: string, tagLine: string, region: string, limit = 10,
|
||||||
): Promise<MatchEntry[]> {
|
): Promise<MatchEntry[]> {
|
||||||
await loadChampionMap();
|
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);
|
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`;
|
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' } });
|
const res = await fetch(gamesUrl, { headers: { 'Accept': 'application/json' } });
|
||||||
|
|
||||||
|
|
@ -204,13 +383,11 @@ export async function getMatches(
|
||||||
const games = json?.data;
|
const games = json?.data;
|
||||||
|
|
||||||
if (!Array.isArray(games) || games.length === 0) {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[LoLStats] Got ${games.length} games from REST API`);
|
console.log(`[LoLStats] Got ${games.length} games from op.gg REST API`);
|
||||||
|
|
||||||
// Transform REST API games to our MatchEntry format
|
|
||||||
return games.map((game: any) => transformGame(game));
|
return games.map((game: any) => transformGame(game));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue