Add Disney Lorcana support via Lorcast API - Complete integration with search, browse, and database population

This commit is contained in:
Randall Stillwell 2025-07-22 20:17:47 -05:00
parent 2f215da3ca
commit e29728e2be
4 changed files with 266 additions and 6 deletions

View file

@ -49,11 +49,21 @@ const response = await fetch('https://db.ygoprodeck.com/api/v7/cardinfo.php?fnam
const cards = await response.json(); const cards = await response.json();
``` ```
### **4. Disney Lorcana** ### **4. Disney Lorcana (Lorcast API)**
**For Lorcana cards** **Best for Lorcana cards**
- **Status**: No official API available yet - **URL**: https://api.lorcast.com/v0
- **Alternative**: Manual data entry or community data scraping - **Free**: No API key required
- **Future**: May have official API when game matures - **Rate Limit**: 10 requests/second (50-100ms delay recommended)
- **Coverage**: All Lorcana cards with images and pricing
- **Status**: Beta API (v0) - production safe
**Example Usage**:
```javascript
// Search for a card
const response = await fetch('https://api.lorcast.com/v0/cards?q=name:Mickey%20Mouse');
const data = await response.json();
const cards = data.cards;
```
## 🚀 **Implementation Strategy** ## 🚀 **Implementation Strategy**
@ -115,6 +125,14 @@ const cards = await response.json();
- `oracle_text`: Effect text - `oracle_text`: Effect text
- `power`/`toughness`: ATK/DEF - `power`/`toughness`: ATK/DEF
#### **Disney Lorcana**
- `mana_cost`: Ink cost
- `cmc`: Ink cost value
- `card_type`: Card type
- `colors`: Ink colors
- `oracle_text`: Card text
- `power`/`toughness`: Strength/Willpower
## 🛠️ **Usage Examples** ## 🛠️ **Usage Examples**
### **Search Cards from External APIs** ### **Search Cards from External APIs**
@ -149,6 +167,7 @@ node scripts/populate-card-database.js
- **Scryfall**: 10 requests/second - **Scryfall**: 10 requests/second
- **Pokémon API**: 5 requests/second - **Pokémon API**: 5 requests/second
- **Yu-Gi-Oh! API**: No strict limits - **Yu-Gi-Oh! API**: No strict limits
- **Lorcast (Lorcana)**: 10 requests/second (50-100ms delay)
### **Caching Strategy** ### **Caching Strategy**
- **Search results**: Cache for 5 minutes - **Search results**: Cache for 5 minutes
@ -218,6 +237,7 @@ if (card) {
- **Scryfall API Docs**: https://scryfall.com/docs/api - **Scryfall API Docs**: https://scryfall.com/docs/api
- **Pokémon TCG API**: https://dev.pokemontcg.io/ - **Pokémon TCG API**: https://dev.pokemontcg.io/
- **Yu-Gi-Oh! API**: https://ygoprodeck.com/api-guide/ - **Yu-Gi-Oh! API**: https://ygoprodeck.com/api-guide/
- **Lorcast API**: https://lorcast.com/docs/api
- **TCGPlayer API**: https://docs.tcgplayer.com/ - **TCGPlayer API**: https://docs.tcgplayer.com/
## 🚨 **Important Notes** ## 🚨 **Important Notes**

View file

@ -50,6 +50,18 @@ const POPULAR_CARDS = {
'Elemental HERO Neos', 'Elemental HERO Neos',
'Stardust Dragon', 'Stardust Dragon',
'Number 39: Utopia' 'Number 39: Utopia'
],
LORCANA: [
'Mickey Mouse - Brave Little Tailor',
'Elsa - Snow Queen',
'Be Prepared',
'Mickey Mouse - Steamboat Pilot',
'Maleficent - Dragon Form',
'Aurora - Briar Rose',
'Stitch - Experiment 626',
'Aladdin - Street Rat',
'Cinderella - Ballroom Sensation',
'Belle - Strange but Special'
] ]
}; };
@ -77,6 +89,7 @@ class RateLimiter {
const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second
const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second
const lorcastLimiter = new RateLimiter(10, 1000); // 10 requests per second (50-100ms delay)
// API Services // API Services
const mtgService = { const mtgService = {
@ -217,6 +230,55 @@ const yugiohService = {
} }
}; };
const lorcanaService = {
async getCardByName(name) {
await lorcastLimiter.waitForSlot();
try {
const response = await fetch(
`https://api.lorcast.com/v0/cards?q=name:${encodeURIComponent(name)}`
);
if (!response.ok) {
console.log(`❌ Lorcana card not found: ${name}`);
return null;
}
const data = await response.json();
if (data.cards.length === 0) {
console.log(`❌ Lorcana card not found: ${name}`);
return null;
}
const card = data.cards[0];
return {
name: card.name,
game: 'LORCANA',
set_name: card.set?.name || '',
set_code: card.set?.code || '',
card_number: card.number,
rarity: card.rarity,
mana_cost: card.cost?.toString(),
cmc: card.cost,
card_type: card.type,
colors: card.colors || [],
oracle_text: card.text || '',
power: card.strength?.toString(),
toughness: card.willpower?.toString(),
image_url: card.image_url,
stock_image_url: card.image_url,
current_price: card.price?.market || null,
market_price: card.price?.low || null,
};
} catch (error) {
console.error(`❌ Error fetching Lorcana card ${name}:`, error.message);
return null;
}
}
};
// Database API // Database API
const databaseAPI = { const databaseAPI = {
async addCard(cardData) { async addCard(cardData) {
@ -280,6 +342,9 @@ async function populateDatabase() {
case 'YUGIOH': case 'YUGIOH':
cardData = await yugiohService.getCardByName(cardName); cardData = await yugiohService.getCardByName(cardName);
break; break;
case 'LORCANA':
cardData = await lorcanaService.getCardByName(cardName);
break;
} }
if (cardData) { if (cardData) {

View file

@ -222,6 +222,16 @@ const CardDatabaseBrowser: React.FC<CardDatabaseBrowserProps> = ({
> >
Yu-Gi-Oh! Yu-Gi-Oh!
</button> </button>
<button
onClick={() => setSelectedGame('LORCANA')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'LORCANA'
? 'bg-purple-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Disney Lorcana
</button>
</div> </div>
{/* Content */} {/* Content */}

View file

@ -4,6 +4,8 @@ import type { Card } from '../types';
const SCRYFALL_BASE_URL = 'https://api.scryfall.com'; const SCRYFALL_BASE_URL = 'https://api.scryfall.com';
const POKEMON_API_BASE_URL = 'https://api.pokemontcg.io/v2'; const POKEMON_API_BASE_URL = 'https://api.pokemontcg.io/v2';
const YUGIOH_API_BASE_URL = 'https://db.ygoprodeck.com/api/v7'; const YUGIOH_API_BASE_URL = 'https://db.ygoprodeck.com/api/v7';
const LORCAST_API_BASE_URL = 'https://api.lorcast.com/v0';
// Rate limiting utilities // Rate limiting utilities
class RateLimiter { class RateLimiter {
@ -33,6 +35,7 @@ class RateLimiter {
// Rate limiters for each API // Rate limiters for each API
const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second
const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second
const lorcastLimiter = new RateLimiter(10, 1000); // 10 requests per second (50-100ms delay)
// ============================================================================ // ============================================================================
// MAGIC: THE GATHERING (Scryfall API) // MAGIC: THE GATHERING (Scryfall API)
@ -274,6 +277,151 @@ export const yugiohService = {
} }
}; };
// ============================================================================
// DISNEY LORCANA API (Lorcast)
// ============================================================================
export const lorcanaService = {
async searchCards(query: string): Promise<Card[]> {
await lorcastLimiter.waitForSlot();
try {
const response = await fetch(
`${LORCAST_API_BASE_URL}/cards?q=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error(`Lorcast API error: ${response.status}`);
}
const data = await response.json();
return data.cards.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set?.name || '',
set_code: card.set?.code || '',
card_number: card.number,
rarity: card.rarity,
game: 'LORCANA',
mana_cost: card.cost?.toString(),
cmc: card.cost,
card_type: card.type,
colors: card.colors || [],
oracle_text: card.text || '',
power: card.strength?.toString(),
toughness: card.willpower?.toString(),
image_url: card.image_url,
stock_image_url: card.image_url,
current_price: card.price?.market || undefined,
market_price: card.price?.low || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching Lorcana cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
await lorcastLimiter.waitForSlot();
try {
const response = await fetch(
`${LORCAST_API_BASE_URL}/cards?q=name:${encodeURIComponent(name)}`
);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data.cards.length === 0) {
return null;
}
const card = data.cards[0];
return {
id: card.id,
name: card.name,
set_name: card.set?.name || '',
set_code: card.set?.code || '',
card_number: card.number,
rarity: card.rarity,
game: 'LORCANA',
mana_cost: card.cost?.toString(),
cmc: card.cost,
card_type: card.type,
colors: card.colors || [],
oracle_text: card.text || '',
power: card.strength?.toString(),
toughness: card.willpower?.toString(),
image_url: card.image_url,
stock_image_url: card.image_url,
current_price: card.price?.market || undefined,
market_price: card.price?.low || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
} catch (error) {
console.error('Error fetching Lorcana card:', error);
return null;
}
},
async getRandomCards(count = 20): Promise<Card[]> {
await lorcastLimiter.waitForSlot();
try {
// Get all cards and randomly select
const response = await fetch(`${LORCAST_API_BASE_URL}/cards`);
if (!response.ok) {
throw new Error(`Lorcast API error: ${response.status}`);
}
const data = await response.json();
const allCards = data.cards || [];
// Randomly select cards
const shuffled = allCards.sort(() => 0.5 - Math.random());
const selectedCards = shuffled.slice(0, count);
return selectedCards.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set?.name || '',
set_code: card.set?.code || '',
card_number: card.number,
rarity: card.rarity,
game: 'LORCANA',
mana_cost: card.cost?.toString(),
cmc: card.cost,
card_type: card.type,
colors: card.colors || [],
oracle_text: card.text || '',
power: card.strength?.toString(),
toughness: card.willpower?.toString(),
image_url: card.image_url,
stock_image_url: card.image_url,
current_price: card.price?.market || undefined,
market_price: card.price?.low || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching random Lorcana cards:', error);
return [];
}
}
};
// ============================================================================ // ============================================================================
// UNIFIED CARD SEARCH // UNIFIED CARD SEARCH
// ============================================================================ // ============================================================================
@ -298,6 +446,11 @@ export const cardDataService = {
results.push(...yugiohCards); results.push(...yugiohCards);
} }
if (!game || game === 'LORCANA') {
const lorcanaCards = await lorcanaService.searchCards(query);
results.push(...lorcanaCards);
}
// Remove duplicates and sort by relevance // Remove duplicates and sort by relevance
const uniqueCards = results.filter((card, index, self) => const uniqueCards = results.filter((card, index, self) =>
index === self.findIndex(c => c.name === card.name && c.game === card.game) index === self.findIndex(c => c.name === card.name && c.game === card.game)
@ -324,6 +477,10 @@ export const cardDataService = {
return await yugiohService.getCardByName(name); return await yugiohService.getCardByName(name);
} }
if (game === 'LORCANA') {
return await lorcanaService.getCardByName(name);
}
// Try all games if no specific game is specified // Try all games if no specific game is specified
const mtgCard = await mtgService.getCardByName(name); const mtgCard = await mtgService.getCardByName(name);
if (mtgCard) return mtgCard; if (mtgCard) return mtgCard;
@ -334,6 +491,9 @@ export const cardDataService = {
const yugiohCard = await yugiohService.getCardByName(name); const yugiohCard = await yugiohService.getCardByName(name);
if (yugiohCard) return yugiohCard; if (yugiohCard) return yugiohCard;
const lorcanaCard = await lorcanaService.getCardByName(name);
if (lorcanaCard) return lorcanaCard;
return null; return null;
} catch (error) { } catch (error) {
console.error('Error in unified card search by name:', error); console.error('Error in unified card search by name:', error);
@ -346,10 +506,15 @@ export const cardDataService = {
try { try {
if (!game || game === 'MTG') { if (!game || game === 'MTG') {
const mtgCards = await mtgService.getRandomCards(Math.ceil(count / 3)); const mtgCards = await mtgService.getRandomCards(Math.ceil(count / 4));
results.push(...mtgCards); results.push(...mtgCards);
} }
if (!game || game === 'LORCANA') {
const lorcanaCards = await lorcanaService.getRandomCards(Math.ceil(count / 4));
results.push(...lorcanaCards);
}
// Note: Pokémon and Yu-Gi-Oh! APIs don't have random card endpoints // Note: Pokémon and Yu-Gi-Oh! APIs don't have random card endpoints
// You could implement random selection from popular cards lists // You could implement random selection from popular cards lists