UNPKG

advanced-search-library

Version:

Intelligent search library with typo correction, autocomplete, and flexible data structure support

516 lines (438 loc) 18.3 kB
/** * Advanced Search Library - Intelligent Search with Typo Correction * * @author Rıdvan Sevindik <sevindikbusiness@gmail.com> * @github https://github.com/Ridvan0 * @linkedin https://www.linkedin.com/in/ridvansevindik/ * @version 1.1.0 */ class AdvancedSearch { constructor(options = {}) { this.data = []; this.searchHistory = []; this.options = { maxResults: options.maxResults || 50, minQueryLength: options.minQueryLength || 1, typoThreshold: options.typoThreshold || 2, historyLimit: options.historyLimit || 100, ...options }; this.searchableFields = options.searchableFields || [ { field: 'name', priority: 10, exact: 20 }, { field: 'title', priority: 10, exact: 20 }, { field: 'description', priority: 5, exact: 10 }, { field: 'category', priority: 8, exact: 15 }, { field: 'brand', priority: 6, exact: 12 }, { field: 'tags', priority: 7, exact: 14 }, { field: 'keywords', priority: 5, exact: 10 }, { field: 'content', priority: 3, exact: 6 }, { field: 'summary', priority: 4, exact: 8 } ]; if (options.customFields) { this.searchableFields = options.customFields; } this.autoDetectFields = options.autoDetectFields !== false; this.characterMap = { 'ç': 'c', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', 'Ç': 'C', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' }; this.typoMap = { 'w': 'v', 'x': 'ks', 'q': 'k', 'tea': 'tea', 'tae': 'tea', 'te': 'tea', 'drum': 'drum', 'drom': 'drum', 'coffee': 'coffee', 'cofee': 'coffee', 'shoes': 'shoes', 'shose': 'shoes', 'phone': 'phone', 'phoen': 'phone', 'computer': 'computer', 'compter': 'computer' }; this.commonWords = [ 'tea', 'coffee', 'water', 'phone', 'computer', 'shoes', 'shirt', 'pants', 'drum', 'guitar', 'book', 'pen' ]; } /** * Add data to search index * @param {Array} items - Array of items to add */ addData(items) { if (!Array.isArray(items)) { items = [items]; } if (this.autoDetectFields && items.length > 0 && this.data.length === 0) { this._detectAndUpdateFields(items[0]); } items.forEach(item => { if (!item.id) { item.id = this.data.length + 1; } item._searchText = this._createSearchText(item); item._searchTextNormalized = this._normalizeText(item._searchText); item._fieldTexts = this._createFieldTexts(item); this.data.push(item); }); } /** * Search data with typo correction and relevance scoring * @param {string} query - Search query * @param {Object} filters - Search filters * @returns {Array} - Filtered and sorted results */ search(query, filters = {}) { if (query && query.length >= this.options.minQueryLength) { this._addToHistory(query); } let results; if (!query || query.length < this.options.minQueryLength) { results = this.data.map(item => ({ ...item, _score: 1 })); } else { const correctedQuery = this._correctTypos(query); const normalizedQuery = this._normalizeText(correctedQuery); results = this.data.map(item => { const score = this._calculateRelevanceScore(item, normalizedQuery, correctedQuery); return { ...item, _score: score }; }); } results = this._applyFilters(results, filters); results = this._sortResults(results, filters.sortBy || 'relevance'); return results .filter(item => item._score > 0) .slice(0, this.options.maxResults) .map(item => { const { _score, _searchText, _searchTextNormalized, ...cleanItem } = item; return { ...cleanItem, relevanceScore: _score }; }); } /** * Get autocomplete suggestions * @param {string} query - Partial query for suggestions * @returns {Array} - Array of suggestions */ autocomplete(query) { if (!query || query.length < 3) { return []; } const normalizedQuery = this._normalizeText(query); const suggestions = new Set(); this.data.forEach(item => { const words = item._searchTextNormalized.split(' '); words.forEach(word => { if (word.startsWith(normalizedQuery) && word.length > normalizedQuery.length) { suggestions.add(word); } }); }); Object.keys(this.typoMap).forEach(typo => { if (typo.startsWith(normalizedQuery)) { suggestions.add(this.typoMap[typo]); } }); this.commonWords.forEach(word => { const normalizedWord = this._normalizeText(word); if (normalizedWord.startsWith(normalizedQuery)) { suggestions.add(word); } }); return Array.from(suggestions).slice(0, 10); } /** * Get search history * @returns {Array} - Array of previous searches */ getSearchHistory() { return this.searchHistory.slice(); } /** * Get search statistics * @returns {Object} - Statistics object */ getStats() { const totalItems = this.data.length; const totalSearches = this.searchHistory.length; const uniqueSearches = new Set(this.searchHistory.map(h => h.query)).size; return { totalItems, totalSearches, uniqueSearches, averageResultsPerSearch: totalSearches > 0 ? this.searchHistory.reduce((sum, h) => sum + h.resultCount, 0) / totalSearches : 0 }; } /** * Correct typos in search query * @param {string} text - Text to correct * @returns {string} - Corrected text */ _correctTypos(text) { let corrected = text.toLowerCase(); Object.keys(this.typoMap).forEach(typo => { const regex = new RegExp(typo, 'gi'); corrected = corrected.replace(regex, this.typoMap[typo]); }); const words = corrected.split(' '); const correctedWords = words.map(word => { if (word.length < 3) return word; let bestMatch = word; let minDistance = this.options.typoThreshold; this.commonWords.forEach(commonWord => { const distance = this._levenshteinDistance(word, commonWord); if (distance < minDistance && distance < word.length / 2) { minDistance = distance; bestMatch = commonWord; } }); return bestMatch; }); return correctedWords.join(' '); } /** * Normalize text for searching * @param {string} text - Text to normalize * @returns {string} - Normalized text */ _normalizeText(text) { if (!text) return ''; let normalized = text.toLowerCase().trim(); normalized = normalized.replace(/\s+/g, ' '); return normalized; } /** * Create searchable text from item * @param {Object} item - Item to process * @returns {string} - Combined searchable text */ _createSearchText(item) { const texts = []; this.searchableFields.forEach(fieldConfig => { const field = fieldConfig.field; if (item[field]) { if (Array.isArray(item[field])) { texts.push(...item[field]); } else { texts.push(item[field]); } } }); return texts.join(' '); } /** * Create field-based texts for priority scoring * @param {Object} item - Item to process * @returns {Object} - Field texts object */ _createFieldTexts(item) { const fieldTexts = {}; this.searchableFields.forEach(fieldConfig => { const field = fieldConfig.field; if (item[field]) { if (Array.isArray(item[field])) { fieldTexts[field] = this._normalizeText(item[field].join(' ')); } else { fieldTexts[field] = this._normalizeText(item[field].toString()); } } }); return fieldTexts; } /** * Auto-detect fields from sample data * @param {Object} sampleItem - Sample item to analyze */ _detectAndUpdateFields(sampleItem) { const detectedFields = []; const existingFields = this.searchableFields.map(f => f.field); detectedFields.push(...this.searchableFields); Object.keys(sampleItem).forEach(key => { if (!existingFields.includes(key) && typeof sampleItem[key] === 'string' || Array.isArray(sampleItem[key])) { let priority = 3; let exact = 6; if (key.toLowerCase().includes('name') || key.toLowerCase().includes('title')) { priority = 10; exact = 20; } else if (key.toLowerCase().includes('description') || key.toLowerCase().includes('desc')) { priority = 5; exact = 10; } else if (key.toLowerCase().includes('tag') || key.toLowerCase().includes('keyword')) { priority = 7; exact = 14; } else if (key.toLowerCase().includes('category') || key.toLowerCase().includes('type')) { priority = 8; exact = 15; } detectedFields.push({ field: key, priority, exact }); } }); this.searchableFields = detectedFields; if (this.options.debug) { console.log('Detected fields:', this.searchableFields.map(f => f.field)); } } /** * Calculate relevance score for item * @param {Object} item - Item to score * @param {string} normalizedQuery - Normalized search query * @param {string} originalQuery - Original search query * @returns {number} - Relevance score */ _calculateRelevanceScore(item, normalizedQuery, originalQuery) { const queryWords = normalizedQuery.split(' ').filter(w => w.length > 0); let totalScore = 0; queryWords.forEach(word => { let wordScore = 0; let bestFieldScore = 0; this.searchableFields.forEach(fieldConfig => { const field = fieldConfig.field; const fieldText = item._fieldTexts[field]; if (!fieldText) return; let fieldScore = 0; if (fieldText === word) { fieldScore += fieldConfig.exact; } else if (fieldText.includes(word)) { fieldScore += fieldConfig.priority; if (fieldText.startsWith(word)) { fieldScore += fieldConfig.priority * 0.5; } const wordPattern = new RegExp(`\\b${word}\\b`, 'i'); if (wordPattern.test(fieldText)) { fieldScore += fieldConfig.priority * 0.3; } } const fieldWords = fieldText.split(' '); fieldWords.forEach(fieldWord => { const distance = this._levenshteinDistance(word, fieldWord); const similarity = 1 - (distance / Math.max(word.length, fieldWord.length)); if (similarity > 0.7) { const fuzzyScore = similarity * fieldConfig.priority * 0.6; fieldScore += fuzzyScore; } }); bestFieldScore = Math.max(bestFieldScore, fieldScore); }); wordScore += bestFieldScore; totalScore += wordScore; }); if (item.viewCount) { totalScore += Math.log(item.viewCount) * 0.1; } if (item.rating) { totalScore += item.rating * 0.5; } this.commonWords.forEach(commonWord => { const normalizedCommon = this._normalizeText(commonWord); this.searchableFields.forEach(fieldConfig => { const fieldText = item._fieldTexts[fieldConfig.field]; if (fieldText && fieldText.includes(normalizedCommon)) { totalScore += 1; } }); }); if (queryWords.length > 1) { const foundWords = queryWords.filter(word => { return this.searchableFields.some(fieldConfig => { const fieldText = item._fieldTexts[fieldConfig.field]; return fieldText && fieldText.includes(word); }); }); if (foundWords.length === queryWords.length) { totalScore += queryWords.length * 2; } } return Math.round(totalScore * 100) / 100; } /** * Apply filters to results * @param {Array} results - Results to filter * @param {Object} filters - Filter options * @returns {Array} - Filtered results */ _applyFilters(results, filters) { let filtered = results; if (filters.priceRange && Array.isArray(filters.priceRange)) { const [min, max] = filters.priceRange; filtered = filtered.filter(item => item.price >= min && item.price <= max ); } if (filters.category) { filtered = filtered.filter(item => item.category && item.category.toLowerCase().includes(filters.category.toLowerCase()) ); } if (filters.brand) { filtered = filtered.filter(item => item.brand && item.brand.toLowerCase().includes(filters.brand.toLowerCase()) ); } if (filters.minRating) { filtered = filtered.filter(item => item.rating >= filters.minRating ); } return filtered; } /** * Sort results by specified criteria * @param {Array} results - Results to sort * @param {string} sortBy - Sort criteria * @returns {Array} - Sorted results */ _sortResults(results, sortBy) { const sortFunctions = { 'relevance': (a, b) => b._score - a._score, 'price_asc': (a, b) => (a.price || 0) - (b.price || 0), 'price_desc': (a, b) => (b.price || 0) - (a.price || 0), 'name_asc': (a, b) => (a.name || '').localeCompare(b.name || ''), 'name_desc': (a, b) => (b.name || '').localeCompare(a.name || ''), 'rating_desc': (a, b) => (b.rating || 0) - (a.rating || 0), 'newest': (a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0) }; const sortFn = sortFunctions[sortBy] || sortFunctions['relevance']; return results.sort(sortFn); } /** * Add search query to history * @param {string} query - Search query */ _addToHistory(query) { const historyEntry = { query: query, timestamp: new Date(), resultCount: 0 }; this.searchHistory.unshift(historyEntry); if (this.searchHistory.length > this.options.historyLimit) { this.searchHistory = this.searchHistory.slice(0, this.options.historyLimit); } } /** * Calculate Levenshtein distance between two strings * @param {string} str1 - First string * @param {string} str2 - Second string * @returns {number} - Edit distance */ _levenshteinDistance(str1, str2) { const matrix = Array(str2.length + 1).fill().map(() => Array(str1.length + 1).fill(0)); for (let i = 0; i <= str1.length; i++) matrix[0][i] = i; for (let j = 0; j <= str2.length; j++) matrix[j][0] = j; for (let j = 1; j <= str2.length; j++) { for (let i = 1; i <= str1.length; i++) { const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; matrix[j][i] = Math.min( matrix[j - 1][i] + 1, matrix[j][i - 1] + 1, matrix[j - 1][i - 1] + cost ); } } return matrix[str2.length][str1.length]; } } // Node.js export if (typeof module !== 'undefined' && module.exports) { module.exports = AdvancedSearch; } // Browser global if (typeof window !== 'undefined') { window.AdvancedSearch = AdvancedSearch; }