Files
chess-project/scripts/fetch-wikipedia-openings.ts
T
Stefan 002ed92bea Add mobile app support and opening training feature
This commit implements iOS/Android mobile app support using Capacitor
and adds a comprehensive opening training feature with LLM-powered
explanations.

## Mobile App Infrastructure

- Add Capacitor configuration for iOS/Android builds
- Create mobile build script that excludes API routes
- Update Next.js config for conditional static export
- Add layout components with generateStaticParams for static builds
- Generate 500+ static pages for offline mobile use

## Chess Engine Abstraction

- Create ChessEngine interface for pluggable implementations
- Add LocalEngine (GPL - uses stockfish.js in browser)
- Add RemoteEngine (proprietary - calls API server)
- Factory pattern selects engine based on environment
- Enables GPL compliance for web, proprietary for mobile

## Opening Training Feature

- Interactive opening repertoire training
- Move validation with engine-backed feedback
- LLM explanations using Gemini API
- Wikipedia integration for opening context
- Opening family grouping (e4, d4, c4, etc.)
- Session state management
- Real-time move feedback with evaluation

Components:
- OpeningSelector: Browse and select openings by family
- OpeningTrainer: Main training interface with chessboard
- MoveFeedback: Display move quality and LLM explanations
- WikipediaSummary: Show opening history and context
- ErrorBoundary: Graceful error handling

Services:
- openingLoader: Load and filter opening database
- engineService: Engine evaluation wrapper
- moveValidator: Validate moves against repertoire
- feedbackGenerator: Generate contextual feedback
- wikipediaService: Fetch and cache Wikipedia data
- sessionManager: Track training session state

## Wikipedia Integration

- Automatic Wikipedia article fetching for openings
- Client-side and server-side caching
- Sanitized summaries with proper formatting
- Link opening database to Wikipedia slugs
- API endpoints for on-demand fetching

## Docker Improvements

- Add entrypoint script for automatic data setup
- Fetch Wikipedia data on first container startup
- Generate opening move index automatically
- Remove generated data from git (public/openings/*.json, public/wikipedia/*.json)
- Add READMEs explaining data requirements
- Update .gitignore for generated files

## Dual Licensing Strategy

- Add LICENSING.md explaining dual licensing approach
- GPL-3.0 for web builds (includes Stockfish)
- Proprietary option for mobile builds (no GPL code)
- Single codebase, multiple licensing models
- Legal compliance documented

## API Endpoints

- POST /api/v1/llm/opening-explanation - Get LLM move explanations
- GET /api/v1/wikipedia/summary - Fetch Wikipedia summaries

## Type Updates

- Add openingTraining types
- Update Tutor component to use ChessEngine interface
- Add Gemini error handling types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 18:40:58 +01:00

448 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env tsx
/**
* Wikipedia Opening Cache Builder
*
* Fetches full Wikipedia articles for chess opening families and caches them locally.
* This eliminates runtime API calls and makes Wikipedia content available offline.
*
* Wikipedia content is licensed under CC BY-SA 3.0
* https://creativecommons.org/licenses/by-sa/3.0/
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface WikipediaArticle {
openingFamily: string;
title: string;
url: string;
sections: {
title: string;
text: string;
}[];
lastModified: string;
license: string;
licenseUrl: string;
fetchedAt: number;
}
/**
* Extract opening family names from the opening database
*/
function extractOpeningFamilies(): string[] {
const ecoFiles = ['ecoA', 'ecoB', 'ecoC', 'ecoD', 'ecoE'];
const families = new Set<string>();
for (const ecoFile of ecoFiles) {
const filePath = path.join(__dirname, '..', 'public', 'openings', `${ecoFile}.json`);
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Extract family names from each opening
for (const opening of Object.values(data) as any[]) {
if (opening.name && opening.isEcoRoot === true) {
const familyName = extractFamilyName(opening.name);
if (familyName) {
families.add(familyName);
}
}
}
}
return Array.from(families).sort();
}
/**
* Extract family name from opening name
*/
function extractFamilyName(openingName: string): string {
const separators = [':', ',', '', '—', ' - '];
for (const sep of separators) {
if (openingName.includes(sep)) {
return openingName.split(sep)[0].trim();
}
}
return openingName;
}
/**
* Manual overrides for problematic opening names
* Maps opening family name -> exact Wikipedia article title
*/
const WIKIPEDIA_OVERRIDES: Record<string, string> = {
'French Defense': 'French Defence',
'French': 'French Defence',
'English Opening': 'English Opening',
'English': 'English Opening',
'Dutch Defense': 'Dutch Defence',
'Dutch': 'Dutch Defence',
'Spanish Game': 'Ruy Lopez',
'Italian Game': 'Italian Game',
'Scandinavian Defense': 'Scandinavian Defense',
'Pirc Defense': 'Pirc Defence',
'Modern Defense': 'Modern Defense (chess)',
};
/**
* Search Wikipedia for the best matching article
*/
async function searchWikipedia(openingFamily: string): Promise<string | null> {
// Check manual overrides first
if (WIKIPEDIA_OVERRIDES[openingFamily]) {
console.log(` Using manual override: "${WIKIPEDIA_OVERRIDES[openingFamily]}"`);
return WIKIPEDIA_OVERRIDES[openingFamily];
}
// Try two search strategies:
// 1. Search with "chess opening" appended (more specific)
// 2. Search with original name (fallback)
const searchQueries = [
`${openingFamily} chess opening`,
openingFamily,
];
for (const query of searchQueries) {
const searchUrl = new URL('https://en.wikipedia.org/w/api.php');
searchUrl.searchParams.set('action', 'opensearch');
searchUrl.searchParams.set('search', query);
searchUrl.searchParams.set('limit', '10'); // Increased from 5 to get more options
searchUrl.searchParams.set('namespace', '0');
searchUrl.searchParams.set('format', 'json');
console.log(` Searching Wikipedia for: "${query}"`);
const response = await fetch(searchUrl.toString(), {
headers: {
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app; cache builder)',
},
});
if (!response.ok) {
console.error(` ❌ Search failed: ${response.status}`);
continue;
}
const data = await response.json();
const titles = data[1] as string[];
const descriptions = data[2] as string[];
if (!titles || titles.length === 0) {
console.log(` ⚠️ No results found for this query`);
continue;
}
// Score each result based on chess relevance
const scoredResults = titles.map((title, i) => {
const description = descriptions[i] || '';
const lowerTitle = title.toLowerCase();
const lowerDesc = description.toLowerCase();
let score = 0;
// Skip disambiguation pages (they're not what we want)
if (lowerTitle.includes('(disambiguation)') || lowerDesc.includes('may refer to')) {
return { title, description, score: -1000 };
}
// Strong chess indicators
if (lowerDesc.includes('chess opening')) score += 100;
if (lowerTitle.includes('chess')) score += 50;
if (lowerDesc.includes('chess')) score += 30;
if (lowerDesc.includes('opening')) score += 20;
// Additional chess terms
if (lowerDesc.includes('variation') || lowerDesc.includes('defense') || lowerDesc.includes('defence')) score += 10;
if (lowerDesc.includes('game') && lowerDesc.includes('chess')) score += 15;
// Prefer exact or close matches to opening name
if (lowerTitle.includes(openingFamily.toLowerCase())) score += 40;
// Penalize generic terms that suggest it's not the chess opening
if (lowerDesc.includes('language') || lowerDesc.includes('people') ||
lowerDesc.includes('cuisine') || lowerDesc.includes('culture')) {
score -= 50;
}
return { title, description, score };
});
// Sort by score (highest first)
scoredResults.sort((a, b) => b.score - a.score);
// Pick the best match if it has a positive score
const bestMatch = scoredResults[0];
if (bestMatch && bestMatch.score > 0) {
console.log(` ✓ Found: "${bestMatch.title}" (score: ${bestMatch.score})`);
return bestMatch.title;
}
}
console.log(` ⚠️ No suitable chess article found`);
return null;
}
/**
* Fetch full Wikipedia article content
*/
async function fetchWikipediaArticle(
openingFamily: string,
articleTitle: string
): Promise<WikipediaArticle | null> {
// Use MediaWiki API to get parsed content with sections
const apiUrl = new URL('https://en.wikipedia.org/w/api.php');
apiUrl.searchParams.set('action', 'parse');
apiUrl.searchParams.set('page', articleTitle);
apiUrl.searchParams.set('prop', 'sections|text|displaytitle|revid');
apiUrl.searchParams.set('format', 'json');
apiUrl.searchParams.set('formatversion', '2');
console.log(` Fetching full article...`);
const response = await fetch(apiUrl.toString(), {
headers: {
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app; cache builder)',
},
});
if (!response.ok) {
console.error(` ❌ Fetch failed: ${response.status}`);
return null;
}
const data = await response.json();
if (data.error) {
console.error(` ❌ API error:`, data.error);
return null;
}
const parseData = data.parse;
const fullHtml = parseData.text;
const sectionsData = parseData.sections || [];
// Extract sections from the HTML
const sections = extractSections(fullHtml, sectionsData);
// Get last modified date
const lastModified = new Date().toISOString();
const article: WikipediaArticle = {
openingFamily,
title: parseData.displaytitle || articleTitle,
url: `https://en.wikipedia.org/wiki/${encodeURIComponent(articleTitle)}`,
sections,
lastModified,
license: 'CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
fetchedAt: Date.now(),
};
// Verify this is actually a chess article
const allText = sections.map(s => s.text).join(' ').toLowerCase();
const isChessArticle =
allText.includes('chess') ||
allText.includes('opening') ||
allText.includes('variation') ||
allText.includes('defense') ||
allText.includes('defence') ||
allText.includes('game') ||
allText.includes('move');
if (!isChessArticle) {
console.log(` ⚠️ Article doesn't appear to be about chess (verification failed)`);
return null;
}
console.log(` ✓ Fetched ${sections.length} sections (verified as chess content)`);
return article;
}
/**
* Extract clean text sections from Wikipedia HTML
*/
function extractSections(
html: string,
sectionsData: any[]
): { title: string; text: string }[] {
// Parse HTML and extract meaningful sections
// For now, we'll get the intro and first few sections
const sections: { title: string; text: string }[] = [];
// Extract intro (text before first heading)
const introMatch = html.match(/<p>([\s\S]*?)(?=<h2|$)/);
if (introMatch) {
const introText = stripHtml(introMatch[1]);
if (introText.trim().length > 50) {
sections.push({
title: 'Introduction',
text: introText,
});
}
}
// Extract sections (we'll take first 5 for brevity)
const relevantSections = sectionsData
.filter((s: any) => s.toclevel === 1) // Top-level sections only
.slice(0, 5);
for (const section of relevantSections) {
const sectionTitle = section.line;
// Skip non-relevant sections
if (
sectionTitle.toLowerCase().includes('references') ||
sectionTitle.toLowerCase().includes('external links') ||
sectionTitle.toLowerCase().includes('see also') ||
sectionTitle.toLowerCase().includes('notes')
) {
continue;
}
// Extract section content
const sectionRegex = new RegExp(
`<h2[^>]*>.*?${escapeRegex(sectionTitle)}.*?</h2>([\s\S]*?)(?=<h2|$)`,
'i'
);
const sectionMatch = html.match(sectionRegex);
if (sectionMatch) {
const sectionText = stripHtml(sectionMatch[1]);
if (sectionText.trim().length > 50) {
sections.push({
title: sectionTitle,
text: sectionText,
});
}
}
}
return sections;
}
/**
* Strip HTML tags and clean text
*/
function stripHtml(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove style tags
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove script tags
.replace(/<sup[^>]*>[\s\S]*?<\/sup>/gi, '') // Remove citation superscripts
.replace(/<\/?[^>]+(>|$)/g, '') // Remove all other tags
.replace(/\[[0-9]+\]/g, '') // Remove citation numbers [1], [2], etc.
.replace(/&nbsp;/g, ' ') // Replace &nbsp;
.replace(/&amp;/g, '&') // Replace &amp;
.replace(/&lt;/g, '<') // Replace &lt;
.replace(/&gt;/g, '>') // Replace &gt;
.replace(/\n\s*\n/g, '\n\n') // Clean up multiple newlines
.trim();
}
/**
* Escape special regex characters
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Main execution
*/
async function main() {
console.log('🌐 Wikipedia Opening Cache Builder\n');
console.log('📚 Extracting opening families from database...');
const families = extractOpeningFamilies();
console.log(`✓ Found ${families.length} unique opening families\n`);
const outputDir = path.join(__dirname, '..', 'public', 'wikipedia');
// Create output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
console.log(`✓ Created directory: ${outputDir}\n`);
}
const results = {
successful: 0,
failed: 0,
skipped: 0,
};
// Process each family
for (const family of families) {
console.log(`\n📖 Processing: ${family}`);
try {
// Search for the article
const articleTitle = await searchWikipedia(family);
if (!articleTitle) {
console.log(` ⚠️ Skipping (no Wikipedia article found)`);
results.skipped++;
continue;
}
// Fetch full article
const article = await fetchWikipediaArticle(family, articleTitle);
if (!article) {
console.log(` ❌ Failed to fetch article`);
results.failed++;
continue;
}
// Save to file
const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const filename = `${slug}.json`;
const filepath = path.join(outputDir, filename);
fs.writeFileSync(filepath, JSON.stringify(article, null, 2));
console.log(` ✓ Saved to: ${filename}`);
results.successful++;
// Rate limiting - be nice to Wikipedia
await sleep(1000);
} catch (error) {
console.error(` ❌ Error:`, error);
results.failed++;
}
}
// Create index file
console.log('\n📝 Creating index file...');
const indexPath = path.join(outputDir, 'index.json');
const indexData = {
generatedAt: new Date().toISOString(),
totalFamilies: families.length,
successful: results.successful,
failed: results.failed,
skipped: results.skipped,
license: 'Wikipedia content licensed under CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
};
fs.writeFileSync(indexPath, JSON.stringify(indexData, null, 2));
// Summary
console.log('\n' + '='.repeat(50));
console.log('✨ Wikipedia Cache Build Complete!\n');
console.log(`✓ Successful: ${results.successful}`);
console.log(`⚠️ Skipped: ${results.skipped}`);
console.log(`❌ Failed: ${results.failed}`);
console.log(`📁 Output: ${outputDir}`);
console.log('='.repeat(50) + '\n');
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Run the script
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});