265 lines
7.6 KiB
TypeScript
265 lines
7.6 KiB
TypeScript
/**
|
|
* Blog Post Management Service
|
|
* Zentrale Verwaltung aller Blog-Beiträge mit automatischer Kategorie-Erkennung
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { cache } from '@/utils/cache';
|
|
|
|
/**
|
|
* Blog Post Interface
|
|
* Definiert die Struktur eines Blog-Beitrags
|
|
*/
|
|
export interface BlogPost {
|
|
/** URL-freundlicher Slug */
|
|
slug: string;
|
|
/** Titel des Beitrags */
|
|
title: string;
|
|
/** Veröffentlichungsdatum (ISO 8601) */
|
|
date: string;
|
|
/** Kurze Zusammenfassung */
|
|
excerpt: string;
|
|
/** Pfad zum Cover-Bild */
|
|
coverImage: string;
|
|
/** Kategorie des Beitrags */
|
|
category?: string;
|
|
/** Schlagwörter/Tags */
|
|
tags?: string[];
|
|
/** Vollständiger Markdown-Inhalt */
|
|
content?: string;
|
|
readingTime?: number;
|
|
}
|
|
|
|
// Verzeichnis der Blog-Beiträge
|
|
const BLOG_POSTS_DIR = path.join(process.cwd(), 'blog-posts');
|
|
|
|
/**
|
|
* Kategorie-Mapping basierend auf Dateinamen- und Inhaltsmustern
|
|
* Ordnet Keywords automatisch passenden Kategorien zu
|
|
*/
|
|
const categoryMap: Record<string, string> = {
|
|
'agentic': 'KI-Agenten',
|
|
'ai-agent': 'KI-Agenten',
|
|
'deepseek': 'KI-Entwicklung',
|
|
'openai': 'KI-Entwicklung',
|
|
'gpt': 'KI-Entwicklung',
|
|
'claude': 'KI-Entwicklung',
|
|
'langchain': 'KI-Entwicklung',
|
|
'langgraph': 'KI-Entwicklung',
|
|
'rag': 'KI-Entwicklung',
|
|
'llm': 'KI-Entwicklung',
|
|
'voice': 'Voice AI',
|
|
'vapi': 'Voice AI',
|
|
'speech': 'Voice AI',
|
|
'elevenlabs': 'Voice AI',
|
|
'whisper': 'Voice AI',
|
|
'n8n': 'Automatisierung',
|
|
'make': 'Automatisierung',
|
|
'zapier': 'Automatisierung',
|
|
'automation': 'Automatisierung',
|
|
'workflow': 'Automatisierung',
|
|
'scraping': 'Web Scraping',
|
|
'playwright': 'Web Scraping',
|
|
'puppeteer': 'Web Scraping',
|
|
'nextjs': 'Web Development',
|
|
'react': 'Web Development',
|
|
'typescript': 'Web Development',
|
|
'api': 'Backend',
|
|
'database': 'Backend',
|
|
'postgres': 'Backend',
|
|
'prisma': 'Backend',
|
|
'supabase': 'Backend',
|
|
'saas': 'SaaS',
|
|
'stripe': 'SaaS',
|
|
'subscription': 'SaaS',
|
|
'seo': 'SEO',
|
|
'performance': 'Performance',
|
|
'iot': 'IoT',
|
|
'raspberry': 'IoT',
|
|
'mqtt': 'IoT',
|
|
'home-assistant': 'Smart Home',
|
|
};
|
|
|
|
/**
|
|
* Erkennt automatisch die Kategorie eines Blog-Posts
|
|
* Analysiert Dateiname und Inhalt auf Keywords
|
|
* @param filename - Dateiname des Blog-Posts
|
|
* @param content - Inhalt des Blog-Posts
|
|
* @returns Die erkannte Kategorie oder 'Technologie' als Fallback
|
|
*/
|
|
function detectCategory(filename: string, content: string): string {
|
|
const lowerFilename = filename.toLowerCase();
|
|
const lowerContent = content.toLowerCase().slice(0, 500);
|
|
|
|
for (const [keyword, category] of Object.entries(categoryMap)) {
|
|
if (lowerFilename.includes(keyword) || lowerContent.includes(keyword)) {
|
|
return category;
|
|
}
|
|
}
|
|
return 'Technologie';
|
|
}
|
|
|
|
/**
|
|
* Berechnet die geschätzte Lesezeit für einen Blog-Post
|
|
* @param content - Der Markdown-Inhalt des Posts
|
|
* @returns Lesezeit in Minuten (mindestens 1)
|
|
*/
|
|
export function calculateReadingTime(content: string): number {
|
|
// Remove markdown syntax for more accurate word count
|
|
const text = content
|
|
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
|
.replace(/`[^`]*`/g, '') // Remove inline code
|
|
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
|
|
.replace(/\[.*?\]\(.*?\)/g, '') // Remove links
|
|
.replace(/[#*_~]/g, '') // Remove markdown formatting
|
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
.trim();
|
|
|
|
// Count words
|
|
const words = text.split(/\s+/).filter(word => word.length > 0).length;
|
|
|
|
// Calculate reading time (average reading speed: 200 words per minute)
|
|
const minutes = Math.ceil(words / 200);
|
|
|
|
return Math.max(1, minutes); // Minimum 1 minute
|
|
}
|
|
|
|
/**
|
|
* Parst einen Markdown-Blog-Post und extrahiert alle Metadaten
|
|
* Extrahiert Titel, Meta-Description, Keywords, Kategorie und generiert Slug
|
|
* @param filename - Dateiname des Markdown-Posts
|
|
* @param content - Vollständiger Markdown-Inhalt
|
|
* @returns BlogPost-Objekt oder null bei Fehler
|
|
*/
|
|
function parseMarkdownPost(filename: string, content: string): BlogPost | null {
|
|
try {
|
|
// Extract title from first H1
|
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
const title = titleMatch ? titleMatch[1].trim() : filename.replace(/\.md$/, '');
|
|
|
|
// Extract meta description
|
|
const metaMatch = content.match(/\*\*Meta-Description:\*\*\s*(.+)/i);
|
|
const metaDescription = metaMatch ? metaMatch[1].trim() : '';
|
|
|
|
// Extract keywords
|
|
const keywordsMatch = content.match(/\*\*Keywords?:\*\*\s*(.+)/i);
|
|
const keywordsStr = keywordsMatch ? keywordsMatch[1].trim() : '';
|
|
const tags = keywordsStr ? keywordsStr.split(',').map(k => k.trim()).slice(0, 6) : [];
|
|
|
|
// Extract excerpt from introduction or meta description
|
|
let excerpt = metaDescription;
|
|
if (!excerpt) {
|
|
const introMatch = content.match(/## Einführung\s*\n\n([\s\S]+?)(?:\n\n|$)/);
|
|
if (introMatch) {
|
|
excerpt = introMatch[1].trim().slice(0, 200) + '...';
|
|
}
|
|
}
|
|
|
|
// Generate slug from filename (remove number prefix)
|
|
const slug = filename
|
|
.replace(/\.md$/, '')
|
|
.replace(/^\d+-/, '');
|
|
|
|
// Extract date from filename number (approximate)
|
|
const numMatch = filename.match(/^(\d+)-/);
|
|
const postNum = numMatch ? parseInt(numMatch[1], 10) : 1;
|
|
// Generate dates starting from 2026-01-19 going backwards
|
|
const baseDate = new Date('2026-01-19');
|
|
baseDate.setDate(baseDate.getDate() - (postNum - 1));
|
|
const date = baseDate.toISOString().split('T')[0];
|
|
|
|
// Detect category
|
|
const category = detectCategory(filename, content);
|
|
|
|
// Cover image path
|
|
const coverImage = `/images/posts/${slug}/cover.jpg`;
|
|
|
|
// Calculate reading time
|
|
const readingTime = calculateReadingTime(content);
|
|
|
|
return {
|
|
slug,
|
|
title,
|
|
date,
|
|
excerpt: excerpt || title,
|
|
coverImage,
|
|
category,
|
|
tags,
|
|
content,
|
|
readingTime,
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error parsing ${filename}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lädt alle Blog-Posts aus dem Dateisystem
|
|
* Posts werden nach Datum absteigend sortiert (neueste zuerst)
|
|
* @returns Array aller Blog-Posts
|
|
*/
|
|
export function getAllBlogPosts(): BlogPost[] {
|
|
const cacheKey = 'blog:all-posts';
|
|
|
|
// Check cache first
|
|
const cachedPosts = cache.get<BlogPost[]>(cacheKey);
|
|
if (cachedPosts) {
|
|
return cachedPosts;
|
|
}
|
|
|
|
if (!fs.existsSync(BLOG_POSTS_DIR)) {
|
|
console.warn('Blog posts directory not found:', BLOG_POSTS_DIR);
|
|
return [];
|
|
}
|
|
|
|
const files = fs.readdirSync(BLOG_POSTS_DIR)
|
|
.filter(file => file.endsWith('.md'))
|
|
.sort();
|
|
|
|
const posts: BlogPost[] = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const filePath = path.join(BLOG_POSTS_DIR, file);
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const post = parseMarkdownPost(file, content);
|
|
if (post) {
|
|
posts.push(post);
|
|
}
|
|
} catch (error) {
|
|
// Skip files that fail to parse
|
|
console.warn(`Failed to parse blog post: ${file}`, error);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Sort by date descending (newest first)
|
|
const sortedPosts = posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
|
|
// Cache the results
|
|
cache.set(cacheKey, sortedPosts);
|
|
|
|
return sortedPosts;
|
|
}
|
|
|
|
/**
|
|
* Sucht einen Blog-Post anhand seines Slugs
|
|
* @param slug - Der URL-Slug des gesuchten Posts
|
|
* @returns Der gefundene Blog-Post oder null
|
|
*/
|
|
export function getBlogPostBySlug(slug: string): BlogPost | null {
|
|
const posts = getAllBlogPosts();
|
|
return posts.find(post => post.slug === slug) || null;
|
|
}
|
|
|
|
/**
|
|
* Gibt alle verfügbaren Blog-Post Slugs zurück
|
|
* Nützlich für statische Seitengenerierung
|
|
* @returns Array aller Post-Slugs
|
|
*/
|
|
export function getBlogPostSlugs(): string[] {
|
|
return getAllBlogPosts().map(post => post.slug);
|
|
}
|