@casoon/auditmysite
Version:
A comprehensive command-line tool for automated accessibility, security, performance, and SEO testing using Playwright and pa11y, based on sitemap URLs
502 lines (419 loc) • 15.5 kB
text/typescript
import { EventEmitter } from 'events';
export interface QueuedUrl {
url: string;
priority: number;
status: 'pending' | 'in-progress' | 'completed' | 'failed' | 'retrying';
attempts: number;
result?: any;
error?: string;
startedAt?: Date;
completedAt?: Date;
duration?: number;
}
export interface QueueEvent {
type: 'url-added' | 'url-started' | 'url-completed' | 'url-failed' | 'url-retrying' | 'queue-empty' | 'progress-update' | 'error';
data: any;
timestamp: Date;
}
export interface EventDrivenQueueOptions {
maxRetries?: number;
maxConcurrent?: number;
priorityPatterns?: Array<{ pattern: string; priority: number }>;
retryDelay?: number;
enableEvents?: boolean;
enableShortStatus?: boolean; // Neue Option für kurze Status-Updates
statusUpdateInterval?: number; // Interval für Status-Updates
eventCallbacks?: {
onUrlAdded?: (url: string, priority: number) => void;
onUrlStarted?: (url: string) => void;
onUrlCompleted?: (url: string, result: any, duration: number) => void;
onUrlFailed?: (url: string, error: string, attempts: number) => void;
onUrlRetrying?: (url: string, attempts: number) => void;
onQueueEmpty?: () => void;
onProgressUpdate?: (stats: QueueStats) => void;
onError?: (error: string) => void;
onShortStatus?: (status: string) => void; // Neuer Callback für kurze Status
};
}
export interface QueueStats {
total: number;
pending: number;
inProgress: number;
completed: number;
failed: number;
retrying: number;
progress: number;
averageDuration: number;
estimatedTimeRemaining: number;
activeWorkers: number;
memoryUsage: number;
cpuUsage: number;
}
export interface ProcessOptions {
processor: (url: string) => Promise<any>; // Funktion die eine URL verarbeitet
onResult?: (url: string, result: any) => void;
onError?: (url: string, error: string) => void;
onProgress?: (stats: QueueStats) => void;
onShortStatus?: (status: string) => void;
}
export class EventDrivenQueue extends EventEmitter {
private queue: QueuedUrl[] = [];
private completed: QueuedUrl[] = [];
private failed: QueuedUrl[] = [];
private activeWorkers: Set<string> = new Set();
private options: EventDrivenQueueOptions;
private isProcessing = false;
private startTime: Date | null = null;
private lastProgressUpdate = 0;
private progressUpdateInterval = 1000; // 1 second
private statusInterval: NodeJS.Timeout | null = null;
constructor(options: EventDrivenQueueOptions = {}) {
super();
this.options = {
maxRetries: 3,
maxConcurrent: 1,
priorityPatterns: [
{ pattern: '/home', priority: 10 },
{ pattern: '/', priority: 9 },
{ pattern: '/about', priority: 8 },
{ pattern: '/contact', priority: 7 }
],
retryDelay: 1000,
enableEvents: true,
enableShortStatus: true, // Standardmäßig aktiviert
statusUpdateInterval: 2000, // 2 Sekunden
...options
};
// Event-Listener für interne Queue-Events
if (this.options.enableEvents) {
this.setupEventListeners();
}
}
private setupEventListeners(): void {
// Interne Event-Handler
this.on('url-added', (url: string, priority: number) => {
this.emit('queue:urlAdded', { url, priority, timestamp: new Date() });
this.options.eventCallbacks?.onUrlAdded?.(url, priority);
});
this.on('url-started', (url: string) => {
this.emit('queue:urlStarted', { url, timestamp: new Date() });
this.options.eventCallbacks?.onUrlStarted?.(url);
});
this.on('url-completed', (url: string, result: any, duration: number) => {
this.emit('queue:urlCompleted', { url, result, duration, timestamp: new Date() });
this.options.eventCallbacks?.onUrlCompleted?.(url, result, duration);
});
this.on('url-failed', (url: string, error: string, attempts: number) => {
this.emit('queue:urlFailed', { url, error, attempts, timestamp: new Date() });
this.options.eventCallbacks?.onUrlFailed?.(url, error, attempts);
});
this.on('url-retrying', (url: string, attempts: number) => {
this.emit('queue:urlRetrying', { url, attempts, timestamp: new Date() });
this.options.eventCallbacks?.onUrlRetrying?.(url, attempts);
});
this.on('queue-empty', () => {
this.emit('queue:empty', { timestamp: new Date() });
this.options.eventCallbacks?.onQueueEmpty?.();
});
this.on('progress-update', (stats: QueueStats) => {
this.emit('queue:progressUpdate', { stats, timestamp: new Date() });
this.options.eventCallbacks?.onProgressUpdate?.(stats);
});
this.on('error', (error: string) => {
this.emit('queue:error', { error, timestamp: new Date() });
this.options.eventCallbacks?.onError?.(error);
});
}
addUrls(urls: string[]): void {
const newUrls = urls.filter(url => !this.queue.some(q => q.url === url));
newUrls.forEach(url => {
const priority = this.calculatePriority(url);
const queuedUrl: QueuedUrl = {
url,
priority,
status: 'pending',
attempts: 0
};
this.queue.push(queuedUrl);
this.emit('url-added', url, priority);
});
// Sortiere nach Priorität (höchste zuerst)
this.queue.sort((a, b) => b.priority - a.priority);
this.updateProgress();
}
getNextUrl(): QueuedUrl | null {
const pendingUrl = this.queue.find(q => q.status === 'pending');
if (pendingUrl && this.activeWorkers.size < this.options.maxConcurrent!) {
pendingUrl.status = 'in-progress';
pendingUrl.startedAt = new Date();
pendingUrl.attempts++;
this.activeWorkers.add(pendingUrl.url);
this.emit('url-started', pendingUrl.url);
this.updateProgress();
return pendingUrl;
}
return null;
}
markCompleted(url: string, result: any): void {
const queuedUrl = this.queue.find(q => q.url === url);
if (queuedUrl) {
queuedUrl.status = 'completed';
queuedUrl.result = result;
queuedUrl.completedAt = new Date();
queuedUrl.duration = queuedUrl.completedAt.getTime() - queuedUrl.startedAt!.getTime();
this.completed.push(queuedUrl);
this.queue = this.queue.filter(q => q.url !== url);
this.activeWorkers.delete(url);
this.emit('url-completed', url, result, queuedUrl.duration);
this.updateProgress();
this.checkQueueEmpty();
}
}
markFailed(url: string, error: string): void {
const queuedUrl = this.queue.find(q => q.url === url);
if (queuedUrl) {
if (queuedUrl.attempts < this.options.maxRetries!) {
// Retry logic
queuedUrl.status = 'retrying';
this.emit('url-retrying', url, queuedUrl.attempts);
// Schedule retry
setTimeout(() => {
queuedUrl.status = 'pending';
this.updateProgress();
}, this.options.retryDelay);
} else {
// Max retries reached
queuedUrl.status = 'failed';
queuedUrl.error = error;
queuedUrl.completedAt = new Date();
queuedUrl.duration = queuedUrl.completedAt.getTime() - queuedUrl.startedAt!.getTime();
this.failed.push(queuedUrl);
this.queue = this.queue.filter(q => q.url !== url);
this.activeWorkers.delete(url);
this.emit('url-failed', url, error, queuedUrl.attempts);
this.updateProgress();
this.checkQueueEmpty();
}
}
}
private checkQueueEmpty(): void {
if (this.queue.length === 0 && this.activeWorkers.size === 0) {
this.emit('queue-empty');
}
}
private updateProgress(): void {
const now = Date.now();
if (now - this.lastProgressUpdate < this.progressUpdateInterval) {
return;
}
this.lastProgressUpdate = now;
const stats = this.getStats();
this.emit('progress-update', stats);
}
getStats(): QueueStats {
const total = this.queue.length + this.completed.length + this.failed.length;
const pending = this.queue.filter(q => q.status === 'pending').length;
const inProgress = this.queue.filter(q => q.status === 'in-progress').length;
const completed = this.completed.length;
const failed = this.failed.length;
const retrying = this.queue.filter(q => q.status === 'retrying').length;
const progress = total > 0 ? ((completed + failed) / total) * 100 : 0;
// Berechne durchschnittliche Dauer
const completedWithDuration = this.completed.filter(q => q.duration);
const averageDuration = completedWithDuration.length > 0
? completedWithDuration.reduce((sum, q) => sum + q.duration!, 0) / completedWithDuration.length
: 0;
// Schätze verbleibende Zeit
const remainingItems = pending + inProgress + retrying;
const estimatedTimeRemaining = remainingItems > 0 && averageDuration > 0
? (remainingItems * averageDuration) / this.options.maxConcurrent!
: 0;
// System-Metriken (vereinfacht)
const memoryUsage = process.memoryUsage().heapUsed / 1024 / 1024; // MB
const cpuUsage = process.cpuUsage().user / 1000000; // Sekunden
return {
total,
pending,
inProgress,
completed,
failed,
retrying,
progress: Math.round(progress * 100) / 100,
averageDuration: Math.round(averageDuration),
estimatedTimeRemaining: Math.round(estimatedTimeRemaining),
activeWorkers: this.activeWorkers.size,
memoryUsage: Math.round(memoryUsage * 100) / 100,
cpuUsage: Math.round(cpuUsage * 100) / 100
};
}
private calculatePriority(url: string): number {
const pattern = this.options.priorityPatterns!.find(p => url.includes(p.pattern));
return pattern ? pattern.priority : 1;
}
/**
* 🚀 Integrierte Parallel-Verarbeitung der Queue
* Verarbeitet alle URLs parallel mit automatischer Status-Berichterstattung
*/
async processUrls(urls: string[], options: ProcessOptions): Promise<any[]> {
console.log(`🚀 Starte Queue-Verarbeitung für ${urls.length} URLs mit ${this.options.maxConcurrent} Workern`);
this.addUrls(urls);
this.startTime = new Date();
// Starte Status-Updates
if (this.options.enableShortStatus) {
this.startStatusUpdates(options.onShortStatus);
}
const results: any[] = [];
const promises: Promise<void>[] = [];
// Erstelle Worker-Promises
for (let i = 0; i < this.options.maxConcurrent!; i++) {
promises.push(this.worker(i, options));
}
// Warte auf alle Worker
await Promise.all(promises);
// Stoppe Status-Updates
this.stopStatusUpdates();
// Sammle alle Ergebnisse
results.push(...this.completed.map(q => q.result));
const duration = Date.now() - this.startTime!.getTime();
console.log(`✅ Queue-Verarbeitung abgeschlossen: ${this.completed.length}/${urls.length} URLs in ${duration}ms`);
return results;
}
/**
* 🔧 Worker-Funktion für parallele Verarbeitung
*/
private async worker(workerId: number, options: ProcessOptions): Promise<void> {
while (this.queue.length > 0 || this.activeWorkers.size > 0) {
const queuedUrl = this.getNextUrl();
if (!queuedUrl) {
// Warte kurz wenn keine URLs verfügbar
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
try {
const result = await options.processor(queuedUrl.url);
this.markCompleted(queuedUrl.url, result);
options.onResult?.(queuedUrl.url, result);
} catch (error) {
this.markFailed(queuedUrl.url, String(error));
options.onError?.(queuedUrl.url, String(error));
}
}
}
/**
* 📊 Startet kurze Status-Updates
*/
private startStatusUpdates(onShortStatus?: (status: string) => void): void {
this.statusInterval = setInterval(() => {
const stats = this.getStats();
const status = this.generateShortStatus(stats);
this.emit('short-status', status);
onShortStatus?.(status);
this.options.eventCallbacks?.onShortStatus?.(status);
}, this.options.statusUpdateInterval);
}
/**
* ⏹️ Stoppt Status-Updates
*/
private stopStatusUpdates(): void {
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = null;
}
}
/**
* 📝 Generiert kurze Status-Nachricht mit verbesserter ETA
*/
private generateShortStatus(stats: QueueStats): string {
const progress = Math.round(stats.progress);
const workers = `${stats.activeWorkers}/${this.options.maxConcurrent}`;
const memory = Math.round(stats.memoryUsage);
// Verbesserte ETA-Berechnung
let eta = '?';
if (stats.estimatedTimeRemaining > 0) {
const seconds = Math.round(stats.estimatedTimeRemaining / 1000);
if (seconds < 60) {
eta = `${seconds}s`;
} else if (seconds < 3600) {
eta = `${Math.round(seconds / 60)}m`;
} else {
eta = `${Math.round(seconds / 3600)}h`;
}
}
// Speed-Indicator (URLs pro Minute)
const speed = stats.completed > 0 && this.startTime
? Math.round((stats.completed / ((Date.now() - this.startTime.getTime()) / 60000)) * 10) / 10
: 0;
// Memory-Warning
const memoryWarning = stats.memoryUsage > 500 ? ' ⚠️' : '';
return `📊 ${progress}% | ${stats.completed}/${stats.total} | 🔧 ${workers} | 💾 ${memory}MB${memoryWarning} | ⏱️ ${eta} | 🚀 ${speed}/min`;
}
// Public API für Event-Listener
onUrlAdded(callback: (event: QueueEvent) => void): this {
this.on('queue:urlAdded', callback);
return this;
}
onUrlStarted(callback: (event: QueueEvent) => void): this {
this.on('queue:urlStarted', callback);
return this;
}
onUrlCompleted(callback: (event: QueueEvent) => void): this {
this.on('queue:urlCompleted', callback);
return this;
}
onUrlFailed(callback: (event: QueueEvent) => void): this {
this.on('queue:urlFailed', callback);
return this;
}
onUrlRetrying(callback: (event: QueueEvent) => void): this {
this.on('queue:urlRetrying', callback);
return this;
}
onQueueEmpty(callback: (event: QueueEvent) => void): this {
this.on('queue:empty', callback);
return this;
}
onProgressUpdate(callback: (event: QueueEvent) => void): this {
this.on('queue:progressUpdate', callback);
return this;
}
onError(callback: (event: QueueEvent) => void): this {
this.on('queue:error', callback);
return this;
}
// Utility-Methoden
getCompletedResults(): any[] {
return this.completed.map(q => q.result);
}
getFailedResults(): any[] {
return this.failed.map(q => ({ url: q.url, error: q.error, attempts: q.attempts }));
}
clear(): void {
this.queue = [];
this.completed = [];
this.failed = [];
this.activeWorkers.clear();
this.isProcessing = false;
this.startTime = null;
}
pause(): void {
this.isProcessing = false;
}
resume(): void {
this.isProcessing = true;
}
isPaused(): boolean {
return !this.isProcessing;
}
getQueueSize(): number {
return this.queue.length;
}
getActiveWorkers(): number {
return this.activeWorkers.size;
}
getMaxConcurrent(): number {
return this.options.maxConcurrent!;
}
setMaxConcurrent(max: number): void {
this.options.maxConcurrent = max;
}
}