deckhearth/lib/scan-gemini.js
varutasu be5dd8027f
fix(scanner): gemini-2.5-flash model + actionable scan error messages (#37)
Use the same vision model as the deleted browser client, surface Gemini
quota/denial/migration failures as 502/503 with readable text, and stop
scan_attempts telemetry from blocking identification.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:42:37 -05:00

130 lines
4.1 KiB
JavaScript

const DEFAULT_VISION_MODEL = process.env.GEMINI_VISION_MODEL || 'gemini-2.5-flash';
const GEMINI_MODEL =
`https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_VISION_MODEL}:generateContent`;
const CARD_PROMPT = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, phone screens, screenshots, blurry images, and non-card gaming items.
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "mtg, pokemon, or lorcana (lowercase)",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative — only extract data you can clearly read. Quality over quantity.`;
function parseGeminiJson(content) {
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
try {
return JSON.parse(cleanContent);
} catch {
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
return {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response',
};
}
}
function normalizeGame(game) {
if (!game) return null;
const value = String(game).trim().toLowerCase();
if (value === 'mtg' || value.includes('magic')) return 'mtg';
if (value.includes('pokemon') || value.includes('pokémon')) return 'pokemon';
if (value.includes('lorcana')) return 'lorcana';
return value;
}
/**
* Server-side Gemini Vision analysis. Requires GEMINI_AI_API_KEY in env.
* @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner
*/
export async function analyzeCardImage(imageDataUrl) {
const apiKey = process.env.GEMINI_AI_API_KEY;
if (!apiKey) {
throw new Error('GEMINI_AI_API_KEY is not configured on the server');
}
const base64Data = imageDataUrl.split(',')[1];
if (!base64Data) {
throw new Error('Invalid image data format');
}
const response = await fetch(GEMINI_MODEL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{
parts: [
{ text: CARD_PROMPT },
{
inline_data: {
mime_type: 'image/jpeg',
data: base64Data,
},
},
],
}],
generationConfig: {
temperature: 0.1,
},
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`
);
}
const data = await response.json();
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!content) {
throw new Error('No response from Gemini API');
}
const result = parseGeminiJson(content);
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: normalizeGame(result.game),
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null,
};
}