class SearchEngine { constructor() { this.posts = []; } setPosts(posts) { this.posts = posts; } // Parse a query like: tag:k8s "container image" category:tech cgroup parseQuery(queryString) { if (!queryString || typeof queryString !== 'string') { return { filters: {}, phrases: [], keywords: [], raw: '' }; } const raw = queryString.trim(); const filters = { tags: [], categories: [], authors: [], langs: [] }; const phrases = []; const keywords = []; // Regex to match: field:value, field:"quoted value", "quoted phrase", or bare words const tokenRegex = /(?:([a-zA-Z_-]+):(?:"([^"]+)"|([^\s]+)))|(?:"([^"]+)")|([^\s]+)/g; let match; while ((match = tokenRegex.exec(raw)) !== null) { if (match[1]) { // Schema filter (e.g. tag:k8s, category:tech) const field = match[1].toLowerCase(); const value = (match[2] || match[3]).trim().toLowerCase(); if (field === 'tag' || field === 'tags') { filters.tags.push(value); } else if (field === 'category' || field === 'cat') { filters.categories.push(value); } else if (field === 'author') { filters.authors.push(value); } else if (field === 'lang') { filters.langs.push(value); } else { // Unknown field, treat as keyword keywords.push(`${field}:${value}`); } } else if (match[4]) { // Quoted exact phrase phrases.push(match[4].trim()); } else if (match[5]) { // Bare keyword keywords.push(match[5].trim()); } } return { filters, phrases, keywords, raw }; } search(queryString) { const parsed = this.parseQuery(queryString); if (!parsed.raw) { return { query: parsed, results: [], total: 0 }; } const results = []; for (const post of this.posts) { let matches = true; let score = 0; // 1. Schema Filter Gate: Category if (parsed.filters.categories.length > 0) { const postCat = (post.category || '').toLowerCase(); const catMatch = parsed.filters.categories.every(cat => postCat === cat); if (!catMatch) matches = false; } // 2. Schema Filter Gate: Tags if (matches && parsed.filters.tags.length > 0) { const postTags = (post.tags || []).map(t => String(t).toLowerCase()); const tagsMatch = parsed.filters.tags.every(reqTag => postTags.some(t => t === reqTag || t.replace(/[^a-z0-9]/g, '') === reqTag.replace(/[^a-z0-9]/g, '')) ); if (!tagsMatch) matches = false; } // 3. Schema Filter Gate: Author if (matches && parsed.filters.authors.length > 0) { const postAuthor = (post.author || '').toLowerCase(); const authorMatch = parsed.filters.authors.every(a => postAuthor.includes(a)); if (!authorMatch) matches = false; } // 4. Schema Filter Gate: Lang if (matches && parsed.filters.langs.length > 0) { const postLang = (post.lang || 'en').toLowerCase(); const availableLangs = (post.availableLanguages || [postLang]).map(l => l.toLowerCase()); const langMatch = parsed.filters.langs.every(l => availableLangs.includes(l)); if (!langMatch) matches = false; } if (!matches) continue; // Searchable text fields const title = post.title || ''; const excerpt = post.excerpt || ''; const content = post.rawContent || ''; const tagsStr = (post.tags || []).join(' '); const fullHaystack = `${title}\n${tagsStr}\n${excerpt}\n${content}`.toLowerCase(); // 5. Exact Phrase Matching (must match ALL quoted phrases) let phraseMatches = true; for (const phrase of parsed.phrases) { const lowerPhrase = phrase.toLowerCase(); if (!fullHaystack.includes(lowerPhrase)) { phraseMatches = false; break; } // Score bonus for phrase if (title.toLowerCase().includes(lowerPhrase)) score += 30; if (excerpt.toLowerCase().includes(lowerPhrase)) score += 15; if (content.toLowerCase().includes(lowerPhrase)) score += 10; } if (!phraseMatches) continue; // 6. Bare Keywords Matching (must match ALL keywords) let keywordMatches = true; for (const kw of parsed.keywords) { const lowerKw = kw.toLowerCase(); if (!fullHaystack.includes(lowerKw)) { keywordMatches = false; break; } if (title.toLowerCase().includes(lowerKw)) score += 15; if (tagsStr.toLowerCase().includes(lowerKw)) score += 10; if (excerpt.toLowerCase().includes(lowerKw)) score += 5; if (content.toLowerCase().includes(lowerKw)) score += 2; } if (!keywordMatches) continue; // Recency score bonus (up to 5 points) if (post.date) { const ageInDays = (Date.now() - new Date(post.date).getTime()) / (1000 * 60 * 60 * 24); if (ageInDays > 0) { score += Math.max(0, 5 - Math.log10(ageInDays)); } } // Generate snippet with highlighted terms const snippet = this.generateSnippet(post, parsed); results.push({ post, score, snippet }); } // Deduplicate by post.slug so bilingual variants don't produce duplicate cards const uniqueResultsMap = new Map(); for (const res of results) { const existing = uniqueResultsMap.get(res.post.slug); if (!existing || res.score > existing.score) { uniqueResultsMap.set(res.post.slug, res); } } const deduplicatedResults = Array.from(uniqueResultsMap.values()); // Sort by relevance score descending deduplicatedResults.sort((a, b) => b.score - a.score); // Log query to SQLite for trending recommendations & analytics try { const dbService = require('./db'); dbService.logSearch(queryString, deduplicatedResults.length); } catch (e) {} return { query: parsed, results: deduplicatedResults, total: deduplicatedResults.length }; } generateSnippet(post, parsed) { const text = post.rawContent || post.excerpt || ''; const searchTerms = [...parsed.phrases, ...parsed.keywords]; if (searchTerms.length === 0) { return post.excerpt ? this.escapeHtml(post.excerpt) : ''; } // Find position of first matching term let bestIndex = -1; let matchedTerm = ''; const lowerText = text.toLowerCase(); for (const term of searchTerms) { const idx = lowerText.indexOf(term.toLowerCase()); if (idx !== -1 && (bestIndex === -1 || idx < bestIndex)) { bestIndex = idx; matchedTerm = term; } } if (bestIndex === -1) { return post.excerpt ? this.escapeHtml(post.excerpt) : ''; } // Extract snippet around bestIndex const start = Math.max(0, bestIndex - 60); const end = Math.min(text.length, bestIndex + 100); let snippet = text.slice(start, end).replace(/\s+/g, ' '); if (start > 0) snippet = '...' + snippet; if (end < text.length) snippet = snippet + '...'; // Highlight terms let safeSnippet = this.escapeHtml(snippet); for (const term of searchTerms) { if (!term) continue; const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi'); safeSnippet = safeSnippet.replace(regex, '$1'); } return safeSnippet; } escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } } module.exports = new SearchEngine();