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>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Add Wikipedia slugs to opening database
|
||||
*
|
||||
* This script updates the opening JSON files to include wikipediaSlug field
|
||||
* based on the cached Wikipedia articles we've already downloaded.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
interface OpeningMetadata {
|
||||
name: string;
|
||||
eco: string;
|
||||
moves: string;
|
||||
isEcoRoot?: boolean;
|
||||
wikipediaSlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert family name to slug
|
||||
*/
|
||||
function familyNameToSlug(familyName: string): string {
|
||||
return familyName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Wikipedia cache index to see what we have
|
||||
*/
|
||||
function getAvailableWikipediaSlugs(): Set<string> {
|
||||
const wikiDir = path.join(__dirname, '..', 'public', 'wikipedia');
|
||||
const slugs = new Set<string>();
|
||||
|
||||
if (!fs.existsSync(wikiDir)) {
|
||||
return slugs;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(wikiDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json') && file !== 'index.json') {
|
||||
const slug = file.replace('.json', '');
|
||||
slugs.add(slug);
|
||||
}
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update opening database files with Wikipedia slugs
|
||||
*/
|
||||
function updateOpeningDatabases() {
|
||||
const availableSlugs = getAvailableWikipediaSlugs();
|
||||
console.log(`\n📚 Found ${availableSlugs.size} Wikipedia cache files\n`);
|
||||
|
||||
const ecoFiles = ['ecoA', 'ecoB', 'ecoC', 'ecoD', 'ecoE'];
|
||||
let totalUpdated = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const ecoFile of ecoFiles) {
|
||||
const filePath = path.join(__dirname, '..', 'public', 'openings', `${ecoFile}.json`);
|
||||
|
||||
console.log(`\n📖 Processing ${ecoFile}.json...`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(` ⚠️ File not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data: Record<string, OpeningMetadata> = JSON.parse(
|
||||
fs.readFileSync(filePath, 'utf-8')
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Update each opening
|
||||
for (const [fen, opening] of Object.entries(data)) {
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
const slug = familyNameToSlug(familyName);
|
||||
|
||||
if (availableSlugs.has(slug)) {
|
||||
opening.wikipediaSlug = slug;
|
||||
updatedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated file
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
|
||||
console.log(` ✓ Updated ${updatedCount} openings`);
|
||||
console.log(` ⚠️ Skipped ${skippedCount} (no Wikipedia cache)`);
|
||||
|
||||
totalUpdated += updatedCount;
|
||||
totalSkipped += skippedCount;
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('✨ Wikipedia Slug Addition Complete!\n');
|
||||
console.log(`✓ Total updated: ${totalUpdated}`);
|
||||
console.log(`⚠️ Total skipped: ${totalSkipped}`);
|
||||
console.log('='.repeat(50) + '\n');
|
||||
}
|
||||
|
||||
// Run the script
|
||||
console.log('🔗 Adding Wikipedia Slugs to Opening Database\n');
|
||||
updateOpeningDatabases();
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mobile build script
|
||||
# Temporarily moves API folder outside src/, builds static export, then restores it
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Preparing mobile build..."
|
||||
|
||||
# Clean previous build (suppress errors for non-empty directories)
|
||||
echo "🧹 Cleaning previous build..."
|
||||
rm -rf .next out 2>/dev/null || true
|
||||
|
||||
# Backup API folder to temp location OUTSIDE src/
|
||||
if [ -d "src/app/api" ]; then
|
||||
echo "📦 Temporarily moving API routes outside src/..."
|
||||
mv src/app/api .api_temp_mobile_build
|
||||
fi
|
||||
|
||||
# Build with mobile configuration
|
||||
echo "🏗️ Building static export for mobile..."
|
||||
BUILD_TARGET=mobile NEXT_PUBLIC_USE_REMOTE_ENGINE=true next build
|
||||
|
||||
# Restore API folder
|
||||
if [ -d ".api_temp_mobile_build" ]; then
|
||||
echo "📦 Restoring API routes..."
|
||||
mv .api_temp_mobile_build src/app/api
|
||||
fi
|
||||
|
||||
echo "✅ Mobile build complete! Output in ./out"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Chess Tutor Docker Entrypoint"
|
||||
|
||||
# Create directories if they don't exist
|
||||
mkdir -p public/openings
|
||||
mkdir -p public/wikipedia
|
||||
|
||||
# Check if Wikipedia cache needs to be populated
|
||||
if [ ! -f "public/wikipedia/.initialized" ] || [ -z "$(ls -A public/wikipedia/*.json 2>/dev/null)" ]; then
|
||||
echo "📚 Fetching Wikipedia opening data..."
|
||||
npm run cache:wikipedia || echo "⚠️ Warning: Wikipedia fetch failed, continuing..."
|
||||
|
||||
echo "🔗 Updating Wikipedia slugs in opening database..."
|
||||
npm run update:wikipedia-slugs || echo "⚠️ Warning: Wikipedia slug update failed, continuing..."
|
||||
|
||||
# Mark as initialized
|
||||
touch public/wikipedia/.initialized
|
||||
echo "✅ Wikipedia data initialized"
|
||||
else
|
||||
echo "✅ Wikipedia cache already populated, skipping..."
|
||||
fi
|
||||
|
||||
# Check if opening database exists
|
||||
if [ ! -f "public/openings/ecoA.json" ]; then
|
||||
echo "⚠️ Warning: Opening database not found in public/openings/"
|
||||
echo " Please ensure opening database files (ecoA-E.json, moveIndex.json) are available"
|
||||
echo " The application will continue but opening training may not work properly"
|
||||
fi
|
||||
|
||||
echo "🚀 Starting Chess Tutor application..."
|
||||
|
||||
# Execute the main command (node server.js)
|
||||
exec "$@"
|
||||
@@ -0,0 +1,447 @@
|
||||
#!/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(/ /g, ' ') // Replace
|
||||
.replace(/&/g, '&') // Replace &
|
||||
.replace(/</g, '<') // Replace <
|
||||
.replace(/>/g, '>') // Replace >
|
||||
.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);
|
||||
});
|
||||
Reference in New Issue
Block a user