← back to Handbag Authentication
api/image-search.js
552 lines
/**
* AI-Powered Image Search
* Searches all databases for visually similar handbags
*/
const fs = require('fs');
const path = require('path');
const axios = require('axios');
class ImageSearchEngine {
constructor() {
this.databases = {
retailer: path.join(__dirname, '..', 'handbag_data', 'processed', 'retailer_handbags.csv'),
luxury: path.join(__dirname, '..', 'handbag_data', 'processed', 'luxury_handbags.csv'),
museum: path.join(__dirname, '..', 'handbag_data', 'processed', 'museum_handbags_normalized.json'),
abo360: path.join(__dirname, '..', 'handbag_data', 'github_datasets', 'amazon-berkeley-objects'),
fancy: path.join(__dirname, '..', 'handbag_data', 'github_datasets', 'FANCY'),
deepfashion: path.join(__dirname, '..', 'handbag_data', 'github_datasets', 'DeepFashion2')
};
}
/**
* Search all databases for a handbag from uploaded image
*/
async searchByImage(imageBuffer, imageUrl = null) {
console.log('🔍 Starting image search across all databases...');
// Step 1: Analyze the uploaded image with Claude AI
const analysis = await this.analyzeImageWithClaude(imageBuffer);
console.log('📊 Image Analysis:', analysis);
// Step 2: Search each database
const results = {
analysis: analysis,
matches: {
retailer: await this.searchRetailerDB(analysis),
luxury: await this.searchLuxuryDB(analysis),
museum: await this.searchMuseumDB(analysis),
abo360: await this.searchABO360DB(analysis),
liveAuctions: await this.searchLiveAuctions(analysis),
total: 0
},
visuallySimilar: [],
priceComparisons: []
};
// Calculate totals
results.matches.total =
results.matches.retailer.length +
results.matches.luxury.length +
results.matches.museum.length +
results.matches.abo360.length +
results.matches.liveAuctions.length;
// Step 3: Find visually similar items
results.visuallySimilar = await this.findVisuallySimilar(analysis);
// Step 4: Get price comparisons
results.priceComparisons = await this.getPriceComparisons(analysis);
return results;
}
/**
* Analyze image with Claude AI
*/
async analyzeImageWithClaude(imageBuffer) {
try {
// Convert image to base64
const base64Image = imageBuffer.toString('base64');
const imageType = this.detectImageType(imageBuffer);
const response = await axios.post(
'https://api.anthropic.com/v1/messages',
{
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: `image/${imageType}`,
data: base64Image
}
},
{
type: 'text',
text: `Analyze this handbag image and extract:
1. Brand (Hermès, Chanel, Louis Vuitton, Gucci, Prada, etc.)
2. Model/Style (Birkin, Kelly, Constance, Boy, Speedy, etc.)
3. Size (if visible: 25, 30, 35, 40, PM, MM, GM)
4. Material (leather type: Togo, Epsom, Clemence, Caviar, Canvas, etc.)
5. Color (be specific: Black, Etoupe, Gold, Rouge, etc.)
6. Hardware (Gold, Silver, Palladium, Rose Gold)
7. Condition (New, Excellent, Good, Fair, Poor)
8. Distinguishing features (stamps, serial numbers, special details)
9. Estimated retail price range (USD)
10. Key search terms for finding similar items
Return as JSON:
{
"brand": "",
"model": "",
"size": "",
"material": "",
"color": "",
"hardware": "",
"condition": "",
"features": [],
"priceRange": {"min": 0, "max": 0},
"searchTerms": [],
"confidence": 0.0
}`
}
]
}
]
},
{
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01'
}
}
);
const textContent = response.data.content.find(c => c.type === 'text');
if (textContent) {
// Extract JSON from response
const jsonMatch = textContent.text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
}
throw new Error('Failed to parse Claude response');
} catch (error) {
console.error('Claude API error:', error.message);
// Fallback: Attempt OCR-like text extraction or basic image analysis
// For now, search broadly across all databases
return {
brand: 'All Brands',
model: 'Search All Models',
size: '',
material: '',
color: '',
hardware: '',
condition: 'Various',
features: ['Searching all databases...'],
priceRange: { min: 0, max: 50000 },
searchTerms: [
'hermès', 'hermes', 'birkin', 'kelly', 'constance',
'chanel', 'classic', 'boy', 'flap',
'louis vuitton', 'lv', 'speedy', 'neverfull', 'alma',
'gucci', 'marmont', 'dionysus',
'prada', 'galleria', 'cahier',
'fendi', 'baguette', 'peekaboo',
'dior', 'lady dior', 'saddle',
'celine', 'luggage', 'belt',
'bottega veneta', 'pouch', 'intrecciato',
'handbag', 'bag', 'purse', 'clutch', 'tote', 'shoulder'
],
confidence: 0.5,
note: 'Broad search mode - upload clear photo of brand logo for better results'
};
}
}
/**
* Detect image type from buffer
*/
detectImageType(buffer) {
const signature = buffer.toString('hex', 0, 4);
if (signature.startsWith('ffd8ff')) return 'jpeg';
if (signature.startsWith('89504e47')) return 'png';
if (signature.startsWith('47494638')) return 'gif';
if (signature.startsWith('52494646')) return 'webp';
return 'jpeg'; // default
}
/**
* Search retailer database
*/
async searchRetailerDB(analysis) {
try {
const csv = require('csv-parser');
const results = [];
if (!fs.existsSync(this.databases.retailer)) return [];
return new Promise((resolve) => {
fs.createReadStream(this.databases.retailer)
.pipe(csv())
.on('data', (row) => {
if (this.matchesAnalysis(row, analysis, 'retailer')) {
results.push({
source: 'retailer',
brand: row.brand,
name: row.product_name,
price: parseFloat(row.price_numeric) || 0,
imageUrl: row.image_url,
url: row.page_url,
retailer: row.retailer
});
}
})
.on('end', () => resolve(results.slice(0, 20)));
});
} catch (error) {
console.error('Retailer search error:', error);
return [];
}
}
/**
* Search luxury database
*/
async searchLuxuryDB(analysis) {
try {
const csv = require('csv-parser');
const results = [];
if (!fs.existsSync(this.databases.luxury)) return [];
return new Promise((resolve) => {
fs.createReadStream(this.databases.luxury)
.pipe(csv())
.on('data', (row) => {
if (this.matchesAnalysis(row, analysis, 'luxury')) {
results.push({
source: 'luxury',
brand: row.brand,
name: row.bag_style,
price: parseFloat(row.price) || 0,
material: row.skin_type,
color: row.major_color,
hardware: row.hardware_metal
});
}
})
.on('end', () => resolve(results.slice(0, 20)));
});
} catch (error) {
console.error('Luxury search error:', error);
return [];
}
}
/**
* Search museum database
*/
async searchMuseumDB(analysis) {
try {
if (!fs.existsSync(this.databases.museum)) return [];
const data = JSON.parse(fs.readFileSync(this.databases.museum, 'utf8'));
return data.filter(item =>
this.matchesAnalysis(item, analysis, 'museum')
).slice(0, 20).map(item => ({
source: 'museum',
brand: item.artist_maker,
name: item.title,
date: item.dated,
culture: item.culture,
imageUrl: item.image_url,
url: item.url
}));
} catch (error) {
console.error('Museum search error:', error);
return [];
}
}
/**
* Search Amazon Berkeley Objects (360° images)
*/
async searchABO360DB(analysis) {
try {
// Check if ABO metadata exists
const metadataPath = path.join(this.databases.abo360, 'listings', 'metadata');
if (!fs.existsSync(metadataPath)) {
console.log('ABO database not downloaded yet');
return [];
}
// Search ABO metadata files
const results = [];
const brand = analysis.brand.toLowerCase();
const model = analysis.model.toLowerCase();
// ABO has 8,222 products - search metadata
const files = fs.readdirSync(metadataPath).slice(0, 100); // Limit search
for (const file of files) {
if (!file.endsWith('.json')) continue;
const data = JSON.parse(fs.readFileSync(path.join(metadataPath, file), 'utf8'));
const title = (data.item_name || '').toLowerCase();
const description = (data.product_description || '').toLowerCase();
if (title.includes(brand) || description.includes(brand) ||
title.includes(model) || description.includes(model)) {
results.push({
source: 'abo360',
name: data.item_name,
asin: data.asin,
brand: data.brand,
has360: true,
imageCount: data.main_image_id ? 24 : 0
});
}
if (results.length >= 20) break;
}
return results;
} catch (error) {
console.error('ABO search error:', error);
return [];
}
}
/**
* Search live auctions (Yahoo JP, Mercari, etc.)
*/
async searchLiveAuctions(analysis) {
try {
const Database = require('../db/schema');
const db = new Database('./data/handbags.db');
return new Promise((resolve) => {
const brand = analysis.brand || '';
const model = analysis.model || '';
db.db.all(`
SELECT *
FROM listings
WHERE (LOWER(brand) LIKE ? OR LOWER(title) LIKE ? OR LOWER(title) LIKE ?)
AND is_active = 1
LIMIT 20
`, [`%${brand.toLowerCase()}%`, `%${brand.toLowerCase()}%`, `%${model.toLowerCase()}%`], (err, rows) => {
db.close();
if (err) {
console.error('Live auction search error:', err);
resolve([]);
} else {
resolve(rows.map(row => ({
source: row.source,
brand: row.brand,
title: row.title,
priceJPY: row.price_jpy,
priceUSD: row.price_usd,
condition: row.condition,
imageUrl: row.image_url,
url: row.product_url
})));
}
});
});
} catch (error) {
console.error('Live auction search error:', error);
return [];
}
}
/**
* Find visually similar items
*/
async findVisuallySimilar(analysis) {
// Search for items with similar characteristics
const similar = [];
// Add items from same brand and model
const searchTerms = [
analysis.brand,
analysis.model,
analysis.color,
analysis.material
].filter(Boolean);
return similar;
}
/**
* Get price comparisons
*/
async getPriceComparisons(analysis) {
const comparisons = [];
// US retail prices (known market values)
const usPrices = this.getUSMarketPrices(analysis.brand, analysis.model);
// Japan auction prices (from our database)
const japanPrices = await this.getJapanPrices(analysis.brand, analysis.model);
if (usPrices) {
comparisons.push({
market: 'US Retail',
priceRange: usPrices,
currency: 'USD'
});
}
if (japanPrices.length > 0) {
const avgJapan = japanPrices.reduce((a, b) => a + b, 0) / japanPrices.length;
comparisons.push({
market: 'Japan Auctions',
priceRange: {
min: Math.min(...japanPrices),
avg: avgJapan,
max: Math.max(...japanPrices)
},
currency: 'USD',
count: japanPrices.length
});
}
return comparisons;
}
/**
* Get US market prices for brand/model
*/
getUSMarketPrices(brand, model) {
const priceDatabase = {
'hermès': {
'birkin 25': { min: 12000, avg: 18000, max: 35000 },
'birkin 30': { min: 13000, avg: 20000, max: 40000 },
'birkin 35': { min: 14000, avg: 22000, max: 45000 },
'kelly 25': { min: 10000, avg: 16000, max: 30000 },
'kelly 28': { min: 11000, avg: 18000, max: 35000 },
'constance': { min: 8000, avg: 12000, max: 25000 }
},
'chanel': {
'classic flap': { min: 4000, avg: 6000, max: 12000 },
'boy bag': { min: 3000, avg: 5000, max: 10000 },
'19 bag': { min: 2500, avg: 4000, max: 8000 }
},
'louis vuitton': {
'speedy': { min: 800, avg: 1200, max: 2000 },
'neverfull': { min: 1000, avg: 1500, max: 2500 },
'alma': { min: 1200, avg: 1800, max: 3000 }
}
};
const brandKey = brand.toLowerCase();
const modelKey = model.toLowerCase();
if (priceDatabase[brandKey] && priceDatabase[brandKey][modelKey]) {
return priceDatabase[brandKey][modelKey];
}
return null;
}
/**
* Get Japan auction prices
*/
async getJapanPrices(brand, model) {
try {
const Database = require('../db/schema');
const db = new Database('./data/handbags.db');
return new Promise((resolve) => {
db.db.all(`
SELECT price_usd
FROM listings
WHERE LOWER(brand) LIKE ?
AND LOWER(title) LIKE ?
AND price_usd > 0
AND is_active = 1
`, [`%${brand.toLowerCase()}%`, `%${model.toLowerCase()}%`], (err, rows) => {
db.close();
if (err || !rows) {
resolve([]);
} else {
resolve(rows.map(r => r.price_usd));
}
});
});
} catch (error) {
return [];
}
}
/**
* Check if item matches analysis
*/
matchesAnalysis(item, analysis, dbType) {
const brand = (analysis.brand || '').toLowerCase();
const model = (analysis.model || '').toLowerCase();
const color = (analysis.color || '').toLowerCase();
let itemBrand = '';
let itemText = '';
if (dbType === 'retailer') {
itemBrand = (item.brand || '').toLowerCase();
itemText = (item.product_name || '').toLowerCase();
} else if (dbType === 'luxury') {
itemBrand = (item.brand || '').toLowerCase();
itemText = (item.bag_style || '').toLowerCase();
} else if (dbType === 'museum') {
itemBrand = (item.artist_maker || '').toLowerCase();
itemText = (item.title || '').toLowerCase();
}
// If broad search mode (no specific brand), match search terms
if (brand === 'all brands' || brand === 'unknown') {
// Check against search terms
for (const term of analysis.searchTerms || []) {
const termLower = term.toLowerCase();
if (itemBrand.includes(termLower) || itemText.includes(termLower)) {
return true;
}
}
return false;
}
// Match brand
if (brand && brand !== 'unknown' && itemBrand.includes(brand)) return true;
// Match model
if (model && model !== 'unknown' && itemText.includes(model)) return true;
// Match color
if (color && itemText.includes(color)) return true;
// Match search terms
for (const term of analysis.searchTerms || []) {
if (itemText.includes(term.toLowerCase()) || itemBrand.includes(term.toLowerCase())) {
return true;
}
}
return false;
}
}
module.exports = ImageSearchEngine;