feat: initial release of pengzhan.dev personal editorial blog platform
- Editorial web platform built with Node.js, Express, EJS, and Vanilla CSS - Markdown content engine with YAML frontmatter, bilingual variants, and hot-reload watcher - Unified SQLite with FTS5 search indexing, query logging, and schema filters - Centered editorial post layout with margin-docked left TOC and mobile bottom sheet - WordPress-grade comment system with moderation dashboard and bulk operations under /guanli - Complete SEO suite (JSON-LD structured data, RSS/Atom feeds, XML sitemap, robots.txt) - CI/CD workflows for linting, security audits (Gitleaks), automated tests, and Docker releases
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
return safeSnippet;
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SearchEngine();
|
||||
Reference in New Issue
Block a user