229 lines
6.7 KiB
TypeScript
229 lines
6.7 KiB
TypeScript
/**
|
|
* Game Import API Integration
|
|
*
|
|
* Fetches chess games from Chess.com and Lichess public APIs
|
|
* Both APIs are free and require no authentication for public games
|
|
*/
|
|
|
|
import { Chess } from 'chess.js';
|
|
|
|
export type Platform = 'chesscom' | 'lichess';
|
|
|
|
export interface GameMetadata {
|
|
id: string;
|
|
platform: Platform;
|
|
white: string;
|
|
black: string;
|
|
result: string; // "1-0", "0-1", "1/2-1/2"
|
|
date: string; // ISO date string
|
|
timeControl: string;
|
|
opening?: string;
|
|
pgn: string;
|
|
finalFen: string; // For thumbnail display
|
|
url?: string; // Link to game on platform
|
|
}
|
|
|
|
interface ChessComGame {
|
|
uuid?: string;
|
|
url?: string;
|
|
pgn: string;
|
|
white?: { username?: string };
|
|
black?: { username?: string };
|
|
end_time: number;
|
|
time_class?: string;
|
|
}
|
|
|
|
interface LichessGame {
|
|
id: string;
|
|
pgn: string;
|
|
status?: string;
|
|
winner?: "white" | "black";
|
|
createdAt: number;
|
|
speed?: string;
|
|
opening?: { eco?: string; name?: string };
|
|
players?: {
|
|
white?: { user?: { name?: string } };
|
|
black?: { user?: { name?: string } };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch games from Chess.com
|
|
* Uses the Published-Data API (PubAPI) - no authentication required
|
|
*
|
|
* @param username - Chess.com username
|
|
* @param maxGames - Maximum number of games to fetch (default: 20)
|
|
* @returns Array of game metadata
|
|
*/
|
|
export async function fetchChessComGames(
|
|
username: string,
|
|
maxGames: number = 20
|
|
): Promise<GameMetadata[]> {
|
|
try {
|
|
// Step 1: Get list of available archives
|
|
const archivesResponse = await fetch(
|
|
`https://api.chess.com/pub/player/${username}/games/archives`,
|
|
{
|
|
headers: {
|
|
'User-Agent': 'ChessTutor/1.0 (Educational App)'
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!archivesResponse.ok) {
|
|
throw new Error(`Chess.com API error: ${archivesResponse.status}`);
|
|
}
|
|
|
|
const archivesData = await archivesResponse.json();
|
|
const archives: string[] = archivesData.archives || [];
|
|
|
|
if (archives.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Step 2: Fetch games from most recent archives until we have enough
|
|
const games: GameMetadata[] = [];
|
|
|
|
// Start from most recent archive
|
|
for (let i = archives.length - 1; i >= 0 && games.length < maxGames; i--) {
|
|
const archiveUrl = archives[i];
|
|
const gamesResponse = await fetch(archiveUrl, {
|
|
headers: {
|
|
'User-Agent': 'ChessTutor/1.0 (Educational App)'
|
|
}
|
|
});
|
|
|
|
if (!gamesResponse.ok) continue;
|
|
|
|
const gamesData = await gamesResponse.json();
|
|
const archiveGames = gamesData.games || [];
|
|
|
|
// Process games in reverse order (most recent first)
|
|
for (let j = archiveGames.length - 1; j >= 0 && games.length < maxGames; j--) {
|
|
const game = archiveGames[j];
|
|
|
|
try {
|
|
const metadata = parseChessComGame(game);
|
|
games.push(metadata);
|
|
} catch (e) {
|
|
console.warn('Failed to parse Chess.com game:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return games;
|
|
} catch (error) {
|
|
console.error('Error fetching Chess.com games:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a Chess.com game object into our GameMetadata format
|
|
*/
|
|
function parseChessComGame(game: ChessComGame): GameMetadata {
|
|
const pgn = game.pgn;
|
|
const chess = new Chess();
|
|
chess.loadPgn(pgn);
|
|
|
|
// Extract metadata from PGN headers
|
|
const headers = chess.header();
|
|
|
|
return {
|
|
id: game.uuid || game.url,
|
|
platform: 'chesscom',
|
|
white: game.white.username || headers.White || 'Unknown',
|
|
black: game.black.username || headers.Black || 'Unknown',
|
|
result: headers.Result || '*',
|
|
date: formatChessComDate(game.end_time),
|
|
timeControl: game.time_class || headers.TimeControl || 'Unknown',
|
|
opening: headers.ECO ? `${headers.ECO}: ${headers.ECOUrl?.split('/').pop()?.replace(/-/g, ' ')}` : undefined,
|
|
pgn: pgn,
|
|
finalFen: chess.fen(),
|
|
url: game.url
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format Chess.com timestamp to ISO date string
|
|
*/
|
|
function formatChessComDate(timestamp: number): string {
|
|
return new Date(timestamp * 1000).toISOString();
|
|
}
|
|
|
|
/**
|
|
* Fetch games from Lichess
|
|
* Uses the Lichess API - no authentication required for public games
|
|
*
|
|
* @param username - Lichess username
|
|
* @param maxGames - Maximum number of games to fetch (default: 20)
|
|
* @returns Array of game metadata
|
|
*/
|
|
export async function fetchLichessGames(
|
|
username: string,
|
|
maxGames: number = 20
|
|
): Promise<GameMetadata[]> {
|
|
try {
|
|
const response = await fetch(
|
|
`https://lichess.org/api/games/user/${username}?max=${maxGames}&pgnInJson=true&clocks=false&evals=false&opening=true`,
|
|
{
|
|
headers: {
|
|
'Accept': 'application/x-ndjson',
|
|
'User-Agent': 'ChessTutor/1.0 (Educational App)'
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Lichess API error: ${response.status}`);
|
|
}
|
|
|
|
const text = await response.text();
|
|
const lines = text.trim().split('\n');
|
|
const games: GameMetadata[] = [];
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
|
|
try {
|
|
const game = JSON.parse(line);
|
|
const metadata = parseLichessGame(game);
|
|
games.push(metadata);
|
|
} catch (e) {
|
|
console.warn('Failed to parse Lichess game:', e);
|
|
}
|
|
}
|
|
|
|
return games;
|
|
} catch (error) {
|
|
console.error('Error fetching Lichess games:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a Lichess game object into our GameMetadata format
|
|
*/
|
|
function parseLichessGame(game: LichessGame): GameMetadata {
|
|
const pgn = game.pgn;
|
|
const chess = new Chess();
|
|
chess.loadPgn(pgn);
|
|
|
|
const players = game.players || {};
|
|
const opening = game.opening;
|
|
|
|
return {
|
|
id: game.id,
|
|
platform: 'lichess',
|
|
white: players.white?.user?.name || 'Unknown',
|
|
black: players.black?.user?.name || 'Unknown',
|
|
result: game.status === 'draw' ? '1/2-1/2' : game.winner === 'white' ? '1-0' : game.winner === 'black' ? '0-1' : '*',
|
|
date: new Date(game.createdAt).toISOString(),
|
|
timeControl: game.speed || 'Unknown',
|
|
opening: opening ? `${opening.eco}: ${opening.name}` : undefined,
|
|
pgn: pgn,
|
|
finalFen: chess.fen(),
|
|
url: `https://lichess.org/${game.id}`
|
|
};
|
|
}
|