feat(lolstats): add data renewal — auto-refresh stale op.gg data

Uses op.gg REST API to trigger summoner data renewal before fetching
stats via MCP. Adds Update button in profile header for manual refresh.
Flow: lookup summoner_id → POST renewal → poll until finish → re-fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 21:22:32 +01:00
parent 40c596fbfa
commit f4c8cce2f9
4 changed files with 218 additions and 3 deletions

View file

@ -2,7 +2,7 @@ import type express from 'express';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { getState, setState } from '../../core/persistence.js';
import { getProfile, getMatches, getMatchDetail, REGIONS } from './opgg-api.js';
import { getProfile, getMatches, getMatchDetail, renewSummoner, REGIONS } from './opgg-api.js';
import type { RecentSearch } from './types.js';
// ── Recent searches ──
@ -105,6 +105,21 @@ const lolstatsPlugin: Plugin = {
}
});
// ── Summoner Renewal (refresh op.gg data) ──
app.post('/api/lolstats/renew', async (req, res) => {
const { gameName, tagLine, region } = req.query as Record<string, string>;
if (!gameName || !tagLine || !region) {
return res.status(400).json({ error: 'gameName, tagLine, region required' });
}
try {
const result = await renewSummoner(gameName, tagLine, region);
res.json(result);
} catch (e: any) {
console.error('[LoLStats] Renewal error:', e.message);
res.status(502).json({ error: e.message });
}
});
// ── Recent Searches ──
app.get('/api/lolstats/recent', (_req, res) => {
res.json(getRecent());

View file

@ -1,6 +1,7 @@
/**
* op.gg MCP Client calls the public op.gg MCP server at https://mcp-api.op.gg/mcp
* Uses JSON-RPC 2.0 over StreamableHTTP (MCP protocol).
* Also uses op.gg REST API for summoner renewal (data refresh).
* No API key needed.
*/
@ -10,6 +11,7 @@ import type {
} from './types.js';
const MCP_URL = 'https://mcp-api.op.gg/mcp';
const OPGG_API = 'https://lol-api-summoner.op.gg/api';
let rpcId = 1;
// ── Regions ──
@ -334,3 +336,108 @@ export async function getMatchDetail(
const parsed = parseClassNotation(raw);
return parsed?.game_detail ?? parsed?.data?.game_detail ?? null;
}
// ── Summoner Renewal (op.gg REST API) ──
// Triggers a data refresh on op.gg's servers so the MCP returns fresh data.
/**
* Look up the op.gg-internal summoner_id for a Riot ID.
* Required for the renewal endpoint.
*/
async function getSummonerId(
gameName: string, tagLine: string, region: string,
): Promise<string> {
const regionLower = region.toLowerCase();
const riotId = `${gameName}-${tagLine}`;
const url = `${OPGG_API}/v3/${regionLower}/summoners?riot_id=${encodeURIComponent(riotId)}&hl=en_US`;
console.log(`[LoLStats] Looking up summoner_id: ${url}`);
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
});
if (!res.ok) {
throw new Error(`op.gg summoner lookup HTTP ${res.status}`);
}
const json = await res.json() as any;
const summonerId = json?.data?.summoner_id ?? json?.data?.id ?? json?.summoner_id;
if (!summonerId) {
// Try to find it in a different structure
const alt = json?.data?.[0]?.summoner_id ?? json?.[0]?.summoner_id;
if (alt) return String(alt);
console.log('[LoLStats] Summoner lookup response:', JSON.stringify(json).slice(0, 500));
throw new Error('Could not find summoner_id in op.gg response');
}
return String(summonerId);
}
/**
* Trigger a data renewal on op.gg and poll until complete.
* Returns the last_updated_at timestamp on success.
*/
export async function renewSummoner(
gameName: string, tagLine: string, region: string,
): Promise<{ renewed: boolean; last_updated_at: string; message?: string }> {
const regionLower = region.toLowerCase();
// Step 1: Get summoner_id
let summonerId: string;
try {
summonerId = await getSummonerId(gameName, tagLine, region);
} catch (e: any) {
console.error('[LoLStats] Summoner lookup failed:', e.message);
throw new Error(`Summoner not found on op.gg: ${e.message}`);
}
console.log(`[LoLStats] Triggering renewal for summoner_id=${summonerId} region=${regionLower}`);
// Step 2: POST renewal
const renewUrl = `${OPGG_API}/${regionLower}/summoners/${encodeURIComponent(summonerId)}/renewal`;
const maxPolls = 10;
for (let attempt = 0; attempt < maxPolls; attempt++) {
const res = await fetch(renewUrl, {
method: 'POST',
headers: { 'Accept': 'application/json' },
});
const json = await res.json() as any;
// Cooldown (already renewed recently) — status 200 but with message
if (json.message?.includes('Already renewed') || json.message?.includes('already')) {
console.log(`[LoLStats] Already renewed recently, last_updated_at=${json.last_updated_at}`);
return {
renewed: true,
last_updated_at: json.last_updated_at ?? '',
message: json.message,
};
}
// Check if finish
const data = json.data ?? json;
if (data.finish === true) {
console.log(`[LoLStats] Renewal complete, last_updated_at=${data.last_updated_at}`);
return {
renewed: true,
last_updated_at: data.last_updated_at ?? '',
};
}
// Not finished — wait and poll again
const delay = Math.min(data.delay ?? 1000, 3000);
console.log(`[LoLStats] Renewal in progress, waiting ${delay}ms (attempt ${attempt + 1}/${maxPolls})`);
await new Promise(r => setTimeout(r, delay));
}
// Timeout — renewal took too long
console.warn('[LoLStats] Renewal polling timeout');
return {
renewed: false,
last_updated_at: '',
message: 'Renewal timed out — data may still be updating',
};
}