advanced-search-library
Version:
Intelligent search library with typo correction, autocomplete, and flexible data structure support
516 lines (438 loc) • 18.3 kB
JavaScript
/**
* 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;
}