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:
parent
40c596fbfa
commit
f4c8cce2f9
4 changed files with 218 additions and 3 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,8 +111,11 @@ export default function LolstatsTab({ data }: { data: any }) {
|
|||
const [expandedMatch, setExpandedMatch] = useState<string | null>(null);
|
||||
const [matchDetails, setMatchDetails] = useState<Record<string, MatchEntry>>({});
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [renewing, setRenewing] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const currentSearchRef = useRef<{ gameName: string; tagLine: string; region: string } | null>(null);
|
||||
|
||||
// Load regions
|
||||
useEffect(() => {
|
||||
|
|
@ -127,8 +130,24 @@ export default function LolstatsTab({ data }: { data: any }) {
|
|||
if (data.regions && !regions.length) setRegions(data.regions);
|
||||
}, [data]);
|
||||
|
||||
// ── Renewal ──
|
||||
const doRenew = useCallback(async (gameName: string, tagLine: string, searchRegion: string) => {
|
||||
setRenewing(true);
|
||||
try {
|
||||
const qs = `gameName=${encodeURIComponent(gameName)}&tagLine=${encodeURIComponent(tagLine)}®ion=${searchRegion}`;
|
||||
const res = await fetch(`/api/lolstats/renew?${qs}`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.last_updated_at) setLastUpdated(data.last_updated_at);
|
||||
return data.renewed ?? false;
|
||||
}
|
||||
} catch {}
|
||||
setRenewing(false);
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// ── Search ──
|
||||
const doSearch = useCallback(async (gn?: string, tl?: string, rg?: string) => {
|
||||
const doSearch = useCallback(async (gn?: string, tl?: string, rg?: string, skipRenew = false) => {
|
||||
let gameName = gn ?? '';
|
||||
let tagLine = tl ?? '';
|
||||
const searchRegion = rg ?? region;
|
||||
|
|
@ -152,6 +171,14 @@ export default function LolstatsTab({ data }: { data: any }) {
|
|||
setExpandedMatch(null);
|
||||
setMatchDetails({});
|
||||
|
||||
// Track current search for update button
|
||||
currentSearchRef.current = { gameName, tagLine, region: searchRegion };
|
||||
|
||||
// Auto-renew on first search (trigger op.gg refresh in background)
|
||||
if (!skipRenew) {
|
||||
doRenew(gameName, tagLine, searchRegion).finally(() => setRenewing(false));
|
||||
}
|
||||
|
||||
try {
|
||||
const qs = `gameName=${encodeURIComponent(gameName)}&tagLine=${encodeURIComponent(tagLine)}®ion=${searchRegion}`;
|
||||
|
||||
|
|
@ -168,6 +195,7 @@ export default function LolstatsTab({ data }: { data: any }) {
|
|||
|
||||
const profileData = await profileRes.json();
|
||||
setProfile(profileData);
|
||||
if (profileData.updated_at) setLastUpdated(profileData.updated_at);
|
||||
|
||||
if (matchesRes.ok) {
|
||||
const matchesData = await matchesRes.json();
|
||||
|
|
@ -177,7 +205,23 @@ export default function LolstatsTab({ data }: { data: any }) {
|
|||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchText, region]);
|
||||
}, [searchText, region, doRenew]);
|
||||
|
||||
// ── Manual Update (renew + re-fetch) ──
|
||||
const doUpdate = useCallback(async () => {
|
||||
const cur = currentSearchRef.current;
|
||||
if (!cur || renewing) return;
|
||||
setRenewing(true);
|
||||
try {
|
||||
await doRenew(cur.gameName, cur.tagLine, cur.region);
|
||||
// Wait a moment for op.gg MCP to pick up the renewed data
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
// Re-fetch
|
||||
await doSearch(cur.gameName, cur.tagLine, cur.region, true);
|
||||
} finally {
|
||||
setRenewing(false);
|
||||
}
|
||||
}, [doRenew, doSearch, renewing]);
|
||||
|
||||
// ── Load more matches ──
|
||||
const loadMore = useCallback(async () => {
|
||||
|
|
@ -384,7 +428,19 @@ export default function LolstatsTab({ data }: { data: any }) {
|
|||
Ladder Rank #{profile.ladder_rank.rank.toLocaleString()} / {profile.ladder_rank.total?.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<div className="lol-profile-updated">Updated {timeAgo(lastUpdated)} ago</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`lol-update-btn ${renewing ? 'renewing' : ''}`}
|
||||
onClick={doUpdate}
|
||||
disabled={renewing}
|
||||
title="Refresh data from Riot servers"
|
||||
>
|
||||
<span className="lol-update-icon">{renewing ? '⟳' : '↻'}</span>
|
||||
{renewing ? 'Updating...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ranked Cards */}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,43 @@
|
|||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.lol-profile-updated {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.lol-profile-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Update Button ── */
|
||||
.lol-update-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lol-update-btn:hover { border-color: var(--accent); color: var(--text-normal); }
|
||||
.lol-update-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.lol-update-btn.renewing { border-color: var(--accent); color: var(--accent); }
|
||||
.lol-update-icon {
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
.lol-update-btn.renewing .lol-update-icon {
|
||||
animation: lol-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Ranked Cards ── */
|
||||
.lol-ranked-row {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue