← back to Handbag Authentication
api/search.js
392 lines
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
class SearchAPI {
constructor(dbPath = '/root/Projects/handbag-authentication/data/handbags.db') {
this.db = new sqlite3.Database(dbPath);
}
/**
* Full-text search across all fields
* @param {string} query - Search query
* @param {object} options - Search options (limit, offset, filters)
*/
fullTextSearch(query, options = {}) {
return new Promise((resolve, reject) => {
const limit = options.limit || 100;
const offset = options.offset || 0;
// Log search for analytics
this.logSearch(query, 'full_text');
const sql = `
SELECT
l.*,
snippet(listings_fts, 2, '<mark>', '</mark>', '...', 50) as title_snippet,
snippet(listings_fts, 8, '<mark>', '</mark>', '...', 100) as description_snippet,
rank
FROM listings l
JOIN listings_fts ON l.id = listings_fts.rowid
WHERE listings_fts MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?
`;
this.db.all(sql, [query, limit, offset], (err, rows) => {
if (err) {
reject(err);
} else {
this.db.get(
'SELECT COUNT(*) as total FROM listings_fts WHERE listings_fts MATCH ?',
[query],
(countErr, countRow) => {
resolve({
results: rows,
total: countErr ? 0 : countRow.total,
query: query,
limit: limit,
offset: offset
});
}
);
}
});
});
}
/**
* Advanced search with multiple filters
*/
advancedSearch(params) {
return new Promise((resolve, reject) => {
let conditions = [];
let values = [];
// Text search
if (params.text) {
conditions.push(`(
title LIKE ? OR
brand LIKE ? OR
model LIKE ? OR
description LIKE ? OR
seller_name LIKE ? OR
condition LIKE ?
)`);
const searchTerm = `%${params.text}%`;
values.push(searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm);
}
// Brand filter
if (params.brand) {
conditions.push('LOWER(brand) = LOWER(?)');
values.push(params.brand);
}
// Model filter
if (params.model) {
conditions.push('LOWER(model) = LOWER(?)');
values.push(params.model);
}
// Price range
if (params.minPrice) {
conditions.push('price_usd >= ?');
values.push(params.minPrice);
}
if (params.maxPrice) {
conditions.push('price_usd <= ?');
values.push(params.maxPrice);
}
// Condition filter
if (params.condition) {
conditions.push('condition LIKE ?');
values.push(`%${params.condition}%`);
}
// Has image filter
if (params.hasImage === true) {
conditions.push('image_url IS NOT NULL');
}
// Source filter
if (params.source) {
conditions.push('source = ?');
values.push(params.source);
}
// Deal only filter
if (params.dealsOnly) {
conditions.push(`
EXISTS (
SELECT 1 FROM deal_analysis da
WHERE da.listing_id = listings.id
AND da.is_deal = 1
)
`);
}
const whereClause = conditions.length > 0
? 'WHERE ' + conditions.join(' AND ')
: '';
// Sorting
let orderBy = 'ORDER BY ';
switch(params.sort) {
case 'price_asc':
orderBy += 'price_usd ASC';
break;
case 'price_desc':
orderBy += 'price_usd DESC';
break;
case 'date_desc':
orderBy += 'crawled_at DESC';
break;
case 'brand':
orderBy += 'brand, model';
break;
default:
orderBy += 'crawled_at DESC';
}
const limit = params.limit || 100;
const offset = params.offset || 0;
values.push(limit, offset);
const sql = `
SELECT * FROM listings
${whereClause}
${orderBy}
LIMIT ? OFFSET ?
`;
// Log search
this.logSearch(JSON.stringify(params), 'advanced');
this.db.all(sql, values, (err, rows) => {
if (err) {
reject(err);
} else {
// Get total count
const countSql = `SELECT COUNT(*) as total FROM listings ${whereClause}`;
const countValues = values.slice(0, -2); // Remove limit and offset
this.db.get(countSql, countValues, (countErr, countRow) => {
resolve({
results: rows,
total: countErr ? 0 : countRow.total,
filters: params,
limit: limit,
offset: offset
});
});
}
});
});
}
/**
* Search by image URL or similar items
*/
searchByImage(imageUrl, listingId) {
return new Promise((resolve, reject) => {
// If listing ID provided, find similar items
if (listingId) {
const sql = `
SELECT
l2.*,
COUNT(*) as similarity_score
FROM listings l1
JOIN listings l2 ON l1.id != l2.id
WHERE l1.id = ?
AND (
l2.brand = l1.brand OR
l2.model = l1.model OR
ABS(l2.price_usd - l1.price_usd) / l1.price_usd < 0.2
)
GROUP BY l2.id
ORDER BY similarity_score DESC
LIMIT 20
`;
this.db.all(sql, [listingId], (err, rows) => {
if (err) {
reject(err);
} else {
resolve({
similar_items: rows,
base_listing_id: listingId
});
}
});
} else {
// Search by direct image URL match
const sql = `
SELECT * FROM listings
WHERE image_url = ? OR thumbnail_url = ?
LIMIT 10
`;
this.db.all(sql, [imageUrl, imageUrl], (err, rows) => {
if (err) {
reject(err);
} else {
resolve({
results: rows,
search_image: imageUrl
});
}
});
}
});
}
/**
* Get search suggestions based on partial input
*/
getSuggestions(query, limit = 10) {
return new Promise((resolve, reject) => {
const sql = `
SELECT DISTINCT
brand || ' ' || COALESCE(model, '') as suggestion,
brand,
model,
COUNT(*) as item_count
FROM listings
WHERE brand LIKE ? OR model LIKE ? OR title LIKE ?
GROUP BY brand, model
ORDER BY item_count DESC
LIMIT ?
`;
const searchTerm = `%${query}%`;
this.db.all(sql, [searchTerm, searchTerm, searchTerm, limit], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
/**
* Get popular searches
*/
getPopularSearches(days = 7, limit = 20) {
return new Promise((resolve, reject) => {
const sql = `
SELECT
search_query,
COUNT(*) as search_count,
MAX(searched_at) as last_searched
FROM search_history
WHERE searched_at >= datetime('now', '-' || ? || ' days')
GROUP BY search_query
ORDER BY search_count DESC
LIMIT ?
`;
this.db.all(sql, [days, limit], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
/**
* Search within specific fields
*/
searchByField(field, value, exactMatch = false) {
return new Promise((resolve, reject) => {
const validFields = ['brand', 'model', 'condition', 'seller_name', 'source', 'location'];
if (!validFields.includes(field)) {
reject(new Error('Invalid search field'));
return;
}
let sql, searchValue;
if (exactMatch) {
sql = `SELECT * FROM listings WHERE LOWER(${field}) = LOWER(?) ORDER BY crawled_at DESC LIMIT 100`;
searchValue = value;
} else {
sql = `SELECT * FROM listings WHERE ${field} LIKE ? ORDER BY crawled_at DESC LIMIT 100`;
searchValue = `%${value}%`;
}
this.db.all(sql, [searchValue], (err, rows) => {
if (err) {
reject(err);
} else {
resolve({
results: rows,
field: field,
value: value,
exact_match: exactMatch
});
}
});
});
}
/**
* Get all unique values for a field (for filters)
*/
getFieldValues(field) {
return new Promise((resolve, reject) => {
const validFields = ['brand', 'model', 'condition', 'source', 'location', 'seller_name'];
if (!validFields.includes(field)) {
reject(new Error('Invalid field'));
return;
}
const sql = `
SELECT
${field} as value,
COUNT(*) as count
FROM listings
WHERE ${field} IS NOT NULL
GROUP BY ${field}
ORDER BY count DESC
`;
this.db.all(sql, [], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
/**
* Log search query for analytics
*/
logSearch(query, searchType) {
const sql = `
INSERT INTO search_history (search_query, search_type)
VALUES (?, ?)
`;
this.db.run(sql, [query, searchType]);
}
/**
* Close database connection
*/
close() {
this.db.close();
}
}
module.exports = SearchAPI;