Files
Content-Hunter_BE/src/modules/content-generation/content-generation.service.ts
Harun CAN c1e081478c
Some checks failed
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled
main
2026-03-23 14:14:52 +03:00

726 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Content Generation Service - Main orchestration
// Path: src/modules/content-generation/content-generation.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../database/prisma.service';
import { NicheService, Niche, NicheAnalysis } from './services/niche.service';
import { DeepResearchService, ResearchResult, ResearchQuery } from './services/deep-research.service';
import { PlatformGeneratorService, Platform, GeneratedContent, MultiPlatformContent } from './services/platform-generator.service';
import { HashtagService, HashtagSet } from './services/hashtag.service';
import { BrandVoiceService, BrandVoice, VoiceApplication } from './services/brand-voice.service';
import { VariationService, VariationSet, VariationOptions } from './services/variation.service';
import { SeoService, FullSeoAnalysis as SeoDTO } from '../seo/seo.service';
import { NeuroMarketingService } from '../neuro-marketing/neuro-marketing.service';
import { StorageService } from '../visual-generation/services/storage.service';
import { VisualGenerationService } from '../visual-generation/visual-generation.service';
import { WebScraperService, ScrapedContent } from '../trends/services/web-scraper.service';
import { ContentType as PrismaContentType, ContentStatus as PrismaContentStatus, MasterContentType as PrismaMasterContentType } from '@prisma/client';
export interface ContentGenerationRequest {
topic: string;
sourceUrl?: string;
niche?: string;
platforms: Platform[];
includeResearch?: boolean;
includeHashtags?: boolean;
brandVoiceId?: string;
variationCount?: number;
writingStyle?: string;
ctaType?: string;
}
export interface SeoAnalysisResult {
score: number;
keywords: string[];
questions: string[];
longTail: { keyword: string; estimatedVolume: number; competitionLevel: string }[];
suggestions: string[];
meta: { title: string; description: string; };
}
export interface NeuroAnalysisResult {
score: number;
triggersUsed: string[];
emotionProfile: string[];
improvements: string[];
}
export interface GeneratedContentBundle {
id: string;
topic: string;
niche?: NicheAnalysis;
research?: ResearchResult;
platforms: GeneratedContent[];
variations: VariationSet[];
seo?: SeoAnalysisResult;
neuro?: NeuroAnalysisResult;
createdAt: Date;
}
@Injectable()
export class ContentGenerationService {
private readonly logger = new Logger(ContentGenerationService.name);
// Visual platforms that should get auto-generated images
private readonly VISUAL_PLATFORMS: Platform[] = ['instagram', 'twitter', 'medium'];
constructor(
private readonly prisma: PrismaService,
private readonly nicheService: NicheService,
private readonly researchService: DeepResearchService,
private readonly platformService: PlatformGeneratorService,
private readonly hashtagService: HashtagService,
private readonly brandVoiceService: BrandVoiceService,
private readonly variationService: VariationService,
private readonly seoService: SeoService,
private readonly neuroService: NeuroMarketingService,
private readonly storageService: StorageService,
private readonly visualService: VisualGenerationService,
private readonly webScraperService: WebScraperService,
) { }
// ========== FULL GENERATION WORKFLOW ==========
/**
* Complete content generation workflow
*/
async generateContent(request: ContentGenerationRequest): Promise<GeneratedContentBundle> {
const {
topic,
sourceUrl,
niche,
platforms,
includeResearch = true,
includeHashtags = true,
brandVoiceId,
variationCount = 3,
writingStyle,
ctaType,
} = request;
console.log(`[ContentGenerationService] Starting generation for topic: ${topic}, platforms: ${platforms.join(', ')}`);
// ========== STEP 1: Scrape source article if URL provided ==========
let scrapedSource: ScrapedContent | null = null;
if (sourceUrl) {
this.logger.log(`Scraping source article: ${sourceUrl}`);
try {
scrapedSource = await this.webScraperService.scrapeUrl(sourceUrl, {
extractImages: true,
extractLinks: true,
timeout: 15000,
}, topic);
if (scrapedSource) {
this.logger.log(`Scraped source: ${scrapedSource.wordCount} words, ${scrapedSource.images.length} images, ${scrapedSource.videoLinks.length} videos`);
} else {
this.logger.warn(`Failed to scrape source URL: ${sourceUrl}`);
}
} catch (err) {
this.logger.warn(`Source scraping error: ${err.message}`);
}
}
// Analyze niche if provided
let nicheAnalysis: NicheAnalysis | undefined;
if (niche) {
nicheAnalysis = this.nicheService.analyzeNiche(niche) || undefined;
}
// Perform research if requested
let research: ResearchResult | undefined;
if (includeResearch) {
research = await this.researchService.research({
topic,
depth: 'standard',
includeStats: true,
includeQuotes: true,
});
}
// ========== Build enriched context from scraped source ==========
let sourceContext = '';
if (scrapedSource) {
const articleText = scrapedSource.content.substring(0, 3000);
const videoInfo = scrapedSource.videoLinks.length > 0
? `\nVİDEO LİNKLERİ: ${scrapedSource.videoLinks.join(', ')}`
: '';
const importantLinks = scrapedSource.links
.filter(l => l.isExternal && !l.href.includes('facebook') && !l.href.includes('twitter'))
.slice(0, 5)
.map(l => `${l.text}: ${l.href}`)
.join('\n');
const linkInfo = importantLinks ? `\nÖNEMLİ LİNKLER:\n${importantLinks}` : '';
sourceContext = `\n\n📰 KAYNAK MAKALE İÇERİĞİ (ZORUNLU REFERANS):\n${articleText}${videoInfo}${linkInfo}\n\n⚠ ÖNEMLİ: Yukarıdaki kaynak makaledeki TÜM özneleri (kişi, ürün, oyun adları, tarihler, fiyatlar, markalar) habere dahil et. Hiçbir önemli bilgiyi atlama. Video linkleri ve önemli dış linkler varsa bunları da içerikte paylaş.`;
}
// Generate content for each platform using AI
const platformContent: GeneratedContent[] = [];
for (const platform of platforms) {
try {
this.logger.log(`Generating for platform: ${platform}`);
// Use AI generation when available
// Sanitize research summary to remove source names/URLs
const sanitizedSummary = this.sanitizeResearchSummary(
research?.summary || `Everything you need to know about ${topic}`
);
// Append scraped source context to give AI the full article details
const enrichedSummary = sanitizedSummary + sourceContext;
// Normalize platform to lowercase for consistency
const normalizedPlatform = platform.toLowerCase();
const aiContent = await this.platformService.generateAIContent(
topic,
enrichedSummary,
normalizedPlatform as any, // Cast to any/Platform to resolve type mismatch if Platform is strict union
'standard',
'tr',
writingStyle,
ctaType,
);
this.logger.log(`AI content length for ${platform}: ${aiContent?.length || 0}`);
if (!aiContent || aiContent.trim().length === 0) {
this.logger.warn(`AI Content is empty for ${platform}`);
}
// Use scraped image from source if available
const sourceImageUrl = scrapedSource?.images?.[0]?.src || undefined;
const config = this.platformService.getPlatformConfig(platform);
let content: GeneratedContent = {
platform,
format: 'AI Generated',
content: aiContent,
hashtags: [],
mediaRecommendations: [],
postingRecommendation: `Best times: ${config.bestPostingTimes.join(', ')}`,
characterCount: aiContent.length,
isWithinLimit: aiContent.length <= config.maxCharacters,
};
// Apply brand voice if specified
if (brandVoiceId) {
const voiceApplied = this.brandVoiceService.applyVoice(content.content, brandVoiceId);
content.content = voiceApplied.branded;
}
// Add hashtags using AI (based on actual generated content)
if (includeHashtags) {
try {
content.hashtags = await this.platformService.generateAIHashtags(
content.content,
topic,
platform as any,
'tr',
);
} catch (hashErr) {
this.logger.warn(`AI hashtag generation failed, skipping: ${hashErr.message}`);
content.hashtags = [];
}
}
// Generate image for visual platforms
if (this.VISUAL_PLATFORMS.includes(platform)) {
try {
this.logger.log(`Generating image for visual platform: ${platform}`);
const platformKey = platform === 'instagram' ? 'instagram_feed' : platform;
const imagePrompt = `${topic} - Professional, high-quality ${platform} visual. Detailed, engaging, and relevant to the topic: ${topic}.`;
const image = await this.visualService.generateImage({
prompt: imagePrompt,
platform: platformKey,
enhancePrompt: true,
});
// Check if image is a real image or just a placeholder
const isPlaceholder = image.url?.includes('placehold.co') || image.url?.includes('placeholder');
if (!isPlaceholder) {
content.imageUrl = image.url;
this.logger.log(`Image generated for ${platform}: ${image.url}`);
} else if (sourceImageUrl) {
// Use scraped source image instead of placeholder
content.imageUrl = sourceImageUrl;
this.logger.log(`Using scraped source image instead of placeholder: ${sourceImageUrl}`);
} else {
content.imageUrl = image.url;
this.logger.log(`Image generated for ${platform}: ${image.url} (placeholder, no source image available)`);
}
} catch (imgError) {
this.logger.warn(`Image generation failed for ${platform}, continuing without image`, imgError);
// Fallback to scraped source image
if (sourceImageUrl) {
content.imageUrl = sourceImageUrl;
this.logger.log(`Using scraped source image as fallback: ${sourceImageUrl}`);
}
}
} else if (sourceImageUrl && !content.imageUrl) {
// For non-visual platforms, still attach source image if available
content.imageUrl = sourceImageUrl;
}
platformContent.push(content);
} catch (error) {
this.logger.error(`Failed to generate for ${platform}`, error);
// Continue to next platform even if one fails
}
}
this.logger.log(`Generated content for ${platformContent.length} platforms`);
// Generate variations for primary platform
const variations: VariationSet[] = [];
if (variationCount > 0 && platformContent.length > 0) {
const primaryContent = platformContent[0].content;
const variationSet = this.variationService.generateVariations(primaryContent, {
count: variationCount,
variationType: 'complete',
language: 'tr',
});
variations.push(variationSet);
}
// SEO Analysis (Full)
let seoResult: SeoAnalysisResult | undefined;
if (platformContent.length > 0) {
try {
const primaryContent = platformContent[0].content;
const fullSeo = await this.seoService.analyzeFull(primaryContent, topic, { language: 'tr' });
const keywordTerms = fullSeo.keywords.related.map(k => k.term);
const questions = fullSeo.keywords.main.questions || [];
const longTail = fullSeo.keywords.longTail.map(lt => ({
keyword: lt.keyword,
estimatedVolume: lt.estimatedVolume,
competitionLevel: lt.competitionLevel,
}));
seoResult = {
score: fullSeo.content.score.overall,
keywords: [fullSeo.keywords.main.term, ...keywordTerms].slice(0, 15),
questions,
longTail: longTail.slice(0, 10),
suggestions: fullSeo.content.score.overall < 70 ? [
'Add more keyword density',
'Include long-tail keywords',
'Add meta description',
'Improve content structure with headings',
] : ['SEO is well optimized', 'Content structure is strong'],
meta: {
title: fullSeo.content.meta.title || `${topic} | Content Hunter`,
description: fullSeo.content.meta.description || research?.summary?.slice(0, 160) || `Learn about ${topic}`,
},
};
} catch (seoError) {
this.logger.warn(`Full SEO analysis failed, falling back to basic`, seoError);
const seoScore = this.seoService.quickScore(platformContent[0].content, topic);
const lsiKeywords = this.seoService.getLSIKeywords(topic, 10);
seoResult = {
score: seoScore,
keywords: lsiKeywords,
questions: [],
longTail: [],
suggestions: seoScore < 70 ? ['Add more keyword density', 'Include long-tail keywords'] : ['SEO is optimized'],
meta: {
title: `${topic} | Content Hunter`,
description: research?.summary?.slice(0, 160) || `Learn about ${topic}`,
},
};
}
}
// Neuro Marketing Analysis
let neuroResult: NeuroAnalysisResult | undefined;
if (platformContent.length > 0) {
const primaryContent = platformContent[0].content;
const analysis = this.neuroService.analyze(primaryContent, platforms[0]);
neuroResult = {
score: analysis.prediction.overallScore,
triggersUsed: analysis.triggerAnalysis.used.map(t => t.name),
emotionProfile: Object.keys(analysis.prediction.categories).filter(
k => analysis.prediction.categories[k as keyof typeof analysis.prediction.categories] > 50
),
improvements: analysis.prediction.improvements,
};
}
return {
id: randomUUID(),
topic,
niche: nicheAnalysis,
research,
platforms: platformContent,
variations,
seo: seoResult,
neuro: neuroResult,
createdAt: new Date(),
};
}
/**
* Persist generated content bundle to database
*/
async saveGeneratedBundle(userId: string | null, bundle: GeneratedContentBundle): Promise<{ masterContentId: string }> {
// If no userId, try to find or create a system anonymous user
let effectiveUserId = userId;
if (!effectiveUserId) {
try {
const anonUser = await this.prisma.user.findFirst({ where: { email: 'anonymous@contenthunter.system' } });
if (anonUser) {
effectiveUserId = anonUser.id;
} else {
const newAnon = await this.prisma.user.create({
data: {
email: 'anonymous@contenthunter.system',
password: 'system-anonymous-no-login',
firstName: 'Anonymous',
},
});
effectiveUserId = newAnon.id;
}
} catch (anonError) {
this.logger.warn(`Could not create anonymous user, content will not be saved: ${anonError.message}`);
return { masterContentId: 'not-saved' };
}
}
console.log(`[ContentGenerationService] Saving bundle for user ${effectiveUserId}, topic: ${bundle.topic}`);
try {
return await this.prisma.$transaction(async (tx) => {
// 1. Create DeepResearch if it exists in bundle
let researchId: string | undefined;
if (bundle.research) {
const research = await tx.deepResearch.create({
data: {
userId: effectiveUserId!,
topic: bundle.topic,
query: bundle.topic,
summary: bundle.research.summary,
sources: JSON.parse(JSON.stringify(bundle.research.sources)),
keyFindings: JSON.parse(JSON.stringify(bundle.research.keyFindings)),
status: 'completed',
completedAt: new Date(),
}
});
researchId = research.id;
}
// 2. Create MasterContent
const masterContent = await tx.masterContent.create({
data: {
userId: effectiveUserId!,
title: bundle.topic,
body: bundle.platforms[0]?.content || '',
type: PrismaMasterContentType.BLOG,
status: PrismaContentStatus.DRAFT,
researchId,
summary: bundle.research?.summary,
}
});
// 3. Create platform-specific content
for (const platformContent of bundle.platforms) {
// Map SocialPlatform/Platform to ContentType enum
let contentType: PrismaContentType = PrismaContentType.BLOG;
if (platformContent.platform === 'twitter') contentType = PrismaContentType.TWITTER;
else if (platformContent.platform === 'instagram') contentType = PrismaContentType.INSTAGRAM;
else if (platformContent.platform === 'linkedin') contentType = PrismaContentType.LINKEDIN;
else if (platformContent.platform === 'facebook') contentType = PrismaContentType.FACEBOOK;
else if (platformContent.platform === 'tiktok') contentType = PrismaContentType.TIKTOK;
const content = await tx.content.create({
data: {
userId: effectiveUserId!,
masterContentId: masterContent.id,
type: contentType,
title: this.sanitizeResearchSummary(`${bundle.topic}`) + ` - ${platformContent.platform}`,
body: platformContent.content,
hashtags: platformContent.hashtags,
status: PrismaContentStatus.DRAFT,
researchId,
}
});
// Save SEO data if available
if (bundle.seo) {
await tx.contentSeo.create({
data: {
contentId: content.id,
metaTitle: bundle.seo.meta.title,
metaDescription: bundle.seo.meta.description,
seoScore: bundle.seo.score,
}
});
}
// Save Psychology data if available
if (bundle.neuro) {
await tx.contentPsychology.create({
data: {
contentId: content.id,
triggersUsed: bundle.neuro.triggersUsed,
engagementScore: bundle.neuro.score,
}
});
}
}
console.log(`[ContentGenerationService] Bundle saved successfully. MasterContentId: ${masterContent.id}`);
// Robust Verification
const savedCount = await tx.content.count({
where: { masterContentId: masterContent.id }
});
if (savedCount !== bundle.platforms.length) {
this.logger.error(`[CRITICAL] Save mismatch! Expected ${bundle.platforms.length} items, found ${savedCount}. MasterID: ${masterContent.id}`);
// Ensure we at least have the master content
}
return { masterContentId: masterContent.id };
});
} catch (error) {
console.error(`[ContentGenerationService] Failed to save bundle:`, error);
throw error;
}
}
// ========== NICHE OPERATIONS ==========
getNiches(): Niche[] {
return this.nicheService.getAllNiches();
}
analyzeNiche(nicheId: string): NicheAnalysis | null {
return this.nicheService.analyzeNiche(nicheId);
}
recommendNiches(interests: string[]): Niche[] {
return this.nicheService.recommendNiches(interests);
}
getContentIdeas(nicheId: string, count?: number): string[] {
return this.nicheService.getContentIdeas(nicheId, count);
}
// ========== RESEARCH OPERATIONS ==========
async research(query: ResearchQuery): Promise<ResearchResult> {
return this.researchService.research(query);
}
async factCheck(claim: string) {
return this.researchService.factCheck(claim);
}
async getContentResearch(topic: string) {
return this.researchService.research({ topic, depth: 'standard', includeStats: true, includeQuotes: true });
}
// ========== PLATFORM OPERATIONS ==========
getPlatforms() {
return this.platformService.getAllPlatforms();
}
getPlatformConfig(platform: Platform) {
return this.platformService.getPlatformConfig(platform);
}
generateForPlatform(platform: Platform, input: { topic: string; mainMessage: string }) {
return this.platformService.generateForPlatform(platform, input);
}
generateMultiPlatform(input: { topic: string; mainMessage: string; platforms: Platform[] }) {
return this.platformService.generateMultiPlatform(input);
}
adaptContent(content: string, from: Platform, to: Platform) {
return this.platformService.adaptContent(content, from, to);
}
// ========== HASHTAG OPERATIONS ==========
generateHashtags(topic: string, platform: string): HashtagSet {
return this.hashtagService.generateHashtags(topic, platform);
}
analyzeHashtag(hashtag: string) {
return this.hashtagService.analyzeHashtag(hashtag);
}
getTrendingHashtags(category?: string) {
return this.hashtagService.getTrendingHashtags(category);
}
// ========== BRAND VOICE OPERATIONS ==========
createBrandVoice(input: Partial<BrandVoice> & { name: string }): BrandVoice {
return this.brandVoiceService.createBrandVoice(input);
}
getBrandVoice(id: string): BrandVoice | null {
return this.brandVoiceService.getBrandVoice(id);
}
listBrandVoicePresets() {
return this.brandVoiceService.listPresets();
}
applyBrandVoice(content: string, voiceId: string): VoiceApplication {
return this.brandVoiceService.applyVoice(content, voiceId);
}
generateVoicePrompt(voiceId: string): string {
return this.brandVoiceService.generateVoicePrompt(voiceId);
}
// ========== VARIATION OPERATIONS ==========
generateVariations(content: string, options?: VariationOptions): VariationSet {
return this.variationService.generateVariations(content, options);
}
createABTest(content: string) {
return this.variationService.createABTest(content);
}
// ========== NEURO REGENERATION ==========
async regenerateForNeuro(request: {
content: string;
platform: string;
currentScore: number;
improvements: string[];
}): Promise<{ content: string; score: number; improvements: string[] }> {
const { content, platform, currentScore, improvements } = request;
// Use platform service to regenerate with neuro optimization
const platformEnum = platform as Platform;
const improvementList = improvements.join('\n- ');
const neuroPrompt = `Sen nöro-pazarlama uzmanı bir sosyal medya içerik yazarısın.
MEVCUT İÇERİK:
${content}
MEVCUT NÖRO SKORU: ${currentScore}/100
İYİLEŞTİRME ÖNERİLERİ:
- ${improvementList}
GÖREV: Yukarıdaki içeriği nöro-pazarlama ilkeleri kullanarak yeniden yaz.
Mevcut mesajı koru ama psikolojik etkiyi artır.
KURALLAR:
1. Güçlü bir hook ile başla (merak, şok, soru)
2. Duygusal tetikleyiciler kullan (korku, heyecan, aidiyet)
3. Sosyal kanıt ekle
4. Aciliyet hissi yarat
5. Güçlü bir CTA ile bitir
6. Karakter limitini koru
7. Platformun tonuna uygun yaz
8. SADECE yayınlanacak metni yaz
9. Hiçbir haber sitesi, kaynak, ajans veya web sitesi adı kullanma
10. "...göre", "...haberlere göre", "...kaynağına göre" gibi atıf ifadeleri ASLA kullanma
SADECE yeniden yazılmış metni döndür, açıklama ekleme.`;
try {
const response = await this.platformService.generateAIContent(
neuroPrompt,
content,
platformEnum,
'standard',
'tr',
);
// Re-analyze with neuro service
const analysis = this.neuroService.analyze(response, platformEnum);
return {
content: response,
score: analysis.prediction.overallScore,
improvements: analysis.prediction.improvements,
};
} catch (error) {
this.logger.error(`Neuro regeneration failed: ${error.message}`);
return {
content,
score: currentScore,
improvements: ['Regeneration failed, try again'],
};
}
}
/**
* Strip source names, URLs, and attribution phrases from research summary
* to prevent them from leaking into generated content.
*/
private sanitizeResearchSummary(summary: string): string {
let sanitized = summary;
// Remove URLs
sanitized = sanitized.replace(/https?:\/\/[^\s]+/gi, '');
sanitized = sanitized.replace(/www\.[^\s]+/gi, '');
// Remove common attribution phrases (Turkish and English)
const attributionPatterns = [
/\b\w+\.com(\.tr)?\b/gi,
/\b\w+\.org(\.tr)?\b/gi,
/\b\w+\.net(\.tr)?\b/gi,
/\bkaynağına göre\b/gi,
/\b'e göre\b/gi,
/\b'(i|a|e|u|ü|\u0131)n(da|de) (yayınlanan|yer alan|çıkan)\b/gi,
/\b(da|de) (çıkan|yayınlanan|yer alan) (haberlere|habere|bilgilere) göre\b/gi,
/\bhaberlere göre\b/gi,
/\braporuna göre\b/gi,
/\bsitesinde yer alan\b/gi,
/\baçıklamasına göre\b/gi,
/\byazısına göre\b/gi,
/\bhaberine göre\b/gi,
/\btarafından yapılan\b/gi,
/\baccording to [^,.]+/gi,
/\breported by [^,.]+/gi,
/\bas reported in [^,.]+/gi,
/\bsource:\s*[^,.]+/gi,
/\breferans:\s*[^,.]+/gi,
/\bkaynak:\s*[^,.]+/gi,
];
// Comprehensive list of Turkish tech/news source brands to strip
const sourceNames = [
'tamindir', 'donanımhaber', 'technopat', 'webtekno', 'shiftdelete',
'chip online', 'log.com', 'mediatrend', 'bbc', 'cnn',
'reuters', 'anadolu ajansı', 'hürriyet', 'milliyet',
'sabah', 'forbes', 'bloomberg', 'techcrunch',
'the verge', 'engadget', 'ars technica', 'wired',
'mashable', 'gizmodo', 'tom\'s hardware', 'tom\'s guide',
'ntv', 'habertürk', 'sozcu', 'sözcü', 'cumhuriyet', 'star',
'posta', 'aksam', 'yeni safak', 'yeni şafak', 'takvim',
'mynet', 'ensonhaber', 'haber7', 'internethaber',
'ad hoc news', 'finanzen.net', 'der aktionär', 'aktionar',
'business insider', 'cnbc', 'financial times', 'wall street journal',
];
for (const pattern of attributionPatterns) {
sanitized = sanitized.replace(pattern, '');
}
for (const source of sourceNames) {
const regex = new RegExp(`\\b${source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
sanitized = sanitized.replace(regex, '');
}
// Also remove "- site_name" patterns from titles (e.g. "Great News - Tamindir")
sanitized = sanitized.replace(/\s*-\s*$/gm, '');
// Clean up multiple spaces, trailing commas, and orphaned punctuation
sanitized = sanitized.replace(/\s{2,}/g, ' ').replace(/,\s*,/g, ',').replace(/\s+([.,;:!?])/g, '$1').trim();
return sanitized;
}
}