Skip to main content
Glama
BulkOperations.ts16.2 kB
/** * Bulk SEO Operations * * This module provides batch processing capabilities for SEO operations, * including metadata updates, content analysis, and schema generation. * It supports chunked processing, progress tracking, retry logic, and dry-run mode. * * @since 2.7.0 */ import { WordPressClient } from "@/client/api.js"; import { MetaGenerator } from "./generators/MetaGenerator.js"; import { ContentAnalyzer } from "./analyzers/ContentAnalyzer.js"; import { SEOCacheManager } from "@/cache/SEOCacheManager.js"; import { LoggerFactory } from "@/utils/logger.js"; import { SEOToolParams, BulkOperationResult, SEOAnalysisResult } from "@/types/seo.js"; import type { WordPressPost } from "@/types/wordpress.js"; /** * Configuration for bulk operations */ interface BulkOperationConfig { /** Number of items per batch */ batchSize: number; /** Maximum number of retry attempts */ maxRetries: number; /** Initial delay for exponential backoff (ms) */ retryDelayMs: number; /** Maximum delay for exponential backoff (ms) */ maxRetryDelayMs: number; /** Timeout per operation (ms) */ operationTimeoutMs: number; /** Enable progress callbacks */ enableProgress: boolean; } /** * Progress information for bulk operations */ interface BulkProgress { /** Total items to process */ total: number; /** Items processed so far */ processed: number; /** Items successfully completed */ completed: number; /** Items that failed */ failed: number; /** Items that were skipped */ skipped: number; /** Current batch being processed */ currentBatch: number; /** Total number of batches */ totalBatches: number; /** Estimated completion time */ eta?: Date; /** Average processing time per item (ms) */ avgProcessingTime: number; } /** * Error information for failed operations */ interface BulkOperationError { /** Post ID that failed */ postId: number; /** Error message */ error: string; /** Retry attempt count */ attempts: number; /** Whether this error is retryable */ retryable: boolean; } /** * Type for progress callback function */ type ProgressCallback = (progress: BulkProgress) => void; /** * Bulk SEO Operations Manager */ export class BulkOperations { private logger = LoggerFactory.tool("bulk_operations"); private config: BulkOperationConfig; private metaGenerator: MetaGenerator; private contentAnalyzer: ContentAnalyzer; private cacheManager: SEOCacheManager | undefined; constructor( private client: WordPressClient, cacheManager?: SEOCacheManager, config?: Partial<BulkOperationConfig>, ) { this.cacheManager = cacheManager; this.metaGenerator = new MetaGenerator(); this.contentAnalyzer = new ContentAnalyzer(); // Default configuration this.config = { batchSize: 10, maxRetries: 3, retryDelayMs: 1000, maxRetryDelayMs: 30000, operationTimeoutMs: 60000, enableProgress: true, ...config, }; } /** * Bulk update metadata for multiple posts */ async bulkUpdateMetadata(params: SEOToolParams, progressCallback?: ProgressCallback): Promise<BulkOperationResult> { const startTime = Date.now(); this.logger.info("Starting bulk metadata update", { postIds: params.postIds?.length, dryRun: params.dryRun, batchSize: this.config.batchSize, }); if (!params.postIds?.length) { throw new Error("No post IDs provided for bulk operation"); } const progress: BulkProgress = { total: params.postIds.length, processed: 0, completed: 0, failed: 0, skipped: 0, currentBatch: 0, totalBatches: Math.ceil(params.postIds.length / this.config.batchSize), avgProcessingTime: 0, }; const errors: BulkOperationError[] = []; const batches = this.createBatches(params.postIds, this.config.batchSize); // Process each batch for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { const batch = batches[batchIndex]; progress.currentBatch = batchIndex + 1; this.logger.debug(`Processing batch ${progress.currentBatch}/${progress.totalBatches}`, { batchSize: batch.length, postIds: batch, }); // Process batch items await Promise.all( batch.map(async (postId) => { const itemStartTime = Date.now(); try { await this.processMetadataUpdate(postId, params); progress.completed++; // Update average processing time const processingTime = Date.now() - itemStartTime; progress.avgProcessingTime = (progress.avgProcessingTime * progress.processed + processingTime) / (progress.processed + 1); } catch (_error) { const bulkError: BulkOperationError = { postId, error: _error instanceof Error ? _error.message : String(_error), attempts: 1, retryable: this.isRetryableError(_error), }; // Attempt retries if (bulkError.retryable) { const retryResult = await this.retryOperation( () => this.processMetadataUpdate(postId, params), bulkError, ); if (retryResult.success) { progress.completed++; } else { progress.failed++; errors.push(retryResult.error); } } else { progress.failed++; errors.push(bulkError); } } progress.processed++; }), ); // Calculate ETA if (progress.avgProcessingTime > 0 && progress.processed < progress.total) { const remainingItems = progress.total - progress.processed; const etaMs = remainingItems * progress.avgProcessingTime; progress.eta = new Date(Date.now() + etaMs); } else if (progress.processed > 0 && progress.processed < progress.total) { // Fallback ETA calculation even with minimal processing time const remainingItems = progress.total - progress.processed; const averageTime = progress.avgProcessingTime || 100; // Fallback to 100ms const etaMs = remainingItems * averageTime; progress.eta = new Date(Date.now() + etaMs); } // Call progress callback if (progressCallback && this.config.enableProgress) { progressCallback(progress); } // Small delay between batches to avoid overwhelming the server if (batchIndex < batches.length - 1) { await this.delay(100); } } const result: BulkOperationResult = { total: params.postIds.length, success: progress.completed, failed: progress.failed, skipped: progress.skipped, errors: errors.map((e) => ({ postId: e.postId, error: e.error })), processingTime: Date.now() - startTime, dryRun: params.dryRun || false, }; this.logger.info("Bulk metadata update completed", { ...result, successRate: ((result.success / result.total) * 100).toFixed(1) + "%", }); return result; } /** * Bulk analyze content for multiple posts */ async bulkAnalyzeContent( params: SEOToolParams, progressCallback?: ProgressCallback, ): Promise<{ results: SEOAnalysisResult[]; summary: BulkOperationResult }> { const startTime = Date.now(); this.logger.info("Starting bulk content analysis", { postIds: params.postIds?.length, analysisType: params.analysisType, batchSize: this.config.batchSize, }); if (!params.postIds?.length) { throw new Error("No post IDs provided for bulk analysis"); } const results: SEOAnalysisResult[] = []; const progress: BulkProgress = { total: params.postIds.length, processed: 0, completed: 0, failed: 0, skipped: 0, currentBatch: 0, totalBatches: Math.ceil(params.postIds.length / this.config.batchSize), avgProcessingTime: 0, }; const errors: BulkOperationError[] = []; const batches = this.createBatches(params.postIds, this.config.batchSize); // Process each batch for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { const batch = batches[batchIndex]; progress.currentBatch = batchIndex + 1; // Process batch items await Promise.all( batch.map(async (postId) => { const itemStartTime = Date.now(); try { const analysisResult = await this.processContentAnalysis(postId, params); results.push(analysisResult); progress.completed++; // Update average processing time const processingTime = Date.now() - itemStartTime; progress.avgProcessingTime = (progress.avgProcessingTime * progress.processed + processingTime) / (progress.processed + 1); } catch (_error) { const bulkError: BulkOperationError = { postId, error: _error instanceof Error ? _error.message : String(_error), attempts: 1, retryable: this.isRetryableError(_error), }; // Attempt retries for analysis if (bulkError.retryable) { const retryResult = await this.retryOperation( () => this.processContentAnalysis(postId, params), bulkError, ); if (retryResult.success) { results.push(retryResult.result!); progress.completed++; } else { progress.failed++; errors.push(retryResult.error); } } else { progress.failed++; errors.push(bulkError); } } progress.processed++; }), ); // Call progress callback if (progressCallback && this.config.enableProgress) { progressCallback(progress); } } const summary: BulkOperationResult = { total: params.postIds.length, success: progress.completed, failed: progress.failed, skipped: progress.skipped, errors: errors.map((e) => ({ postId: e.postId, error: e.error })), processingTime: Date.now() - startTime, dryRun: false, }; this.logger.info("Bulk content analysis completed", { ...summary, resultsCount: results.length, avgScore: results.length > 0 ? (results.reduce((sum, r) => sum + r.score, 0) / results.length).toFixed(1) : 0, }); return { results, summary }; } /** * Process metadata update for a single post */ private async processMetadataUpdate(postId: number, params: SEOToolParams): Promise<void> { // Check cache first const cacheKey = `bulk-meta-${postId}`; if (this.cacheManager && !params.force) { const cached = this.cacheManager.get(cacheKey); if (cached) { this.logger.debug("Using cached metadata", { postId }); return; } } // Fetch post data const post = await this.client.getPost(postId); if (!post) { throw new Error(`Post ${postId} not found`); } // Generate metadata const metadataParams = { postId, ...(params.focusKeywords && { focusKeywords: params.focusKeywords }), ...(params.site && { site: params.site }), }; const metadata = await this.metaGenerator.generateMetadata(post, metadataParams); if (!params.dryRun) { // Apply updates to WordPress (this would need actual WordPress API calls) // For now, we just simulate the update this.logger.debug("Applying metadata updates", { postId, titleLength: metadata.title.length, descriptionLength: metadata.description.length, }); // In a real implementation, you would call: // await this.client.updatePost(postId, { meta: metadata }); } // Cache the result if (this.cacheManager) { this.cacheManager.set(cacheKey, metadata, 3600); // 1 hour } } /** * Process content analysis for a single post */ private async processContentAnalysis(postId: number, params: SEOToolParams): Promise<SEOAnalysisResult> { // Check cache first const cacheKey = `bulk-analysis-${postId}-${params.analysisType || "full"}`; if (this.cacheManager && !params.force) { const cached = this.cacheManager.get(cacheKey); if (cached) { this.logger.debug("Using cached analysis", { postId }); return cached as SEOAnalysisResult; } } // Fetch post data const post = await this.client.getPost(postId); if (!post) { throw new Error(`Post ${postId} not found`); } // Analyze content const analysisParams = { postId, analysisType: params.analysisType || ("full" as const), ...(params.site && { site: params.site }), ...(params.focusKeywords && { focusKeywords: params.focusKeywords }), }; const analysisResult = await this.contentAnalyzer.analyzePost(post as WordPressPost, analysisParams); // Cache the result if (this.cacheManager) { this.cacheManager.set(cacheKey, analysisResult, 21600); // 6 hours } return analysisResult; } /** * Retry operation with exponential backoff */ private async retryOperation<T>( operation: () => Promise<T>, error: BulkOperationError, ): Promise<{ success: boolean; result?: T; error: BulkOperationError }> { let delay = this.config.retryDelayMs; for (let attempt = 2; attempt <= this.config.maxRetries + 1; attempt++) { await this.delay(delay); try { const result = await operation(); this.logger.debug("Retry successful", { postId: error.postId, attempt, delay, }); return { success: true, result, error }; } catch (retryError) { error.attempts = attempt; error.error = retryError instanceof Error ? retryError.message : String(retryError); // Exponential backoff delay = Math.min(delay * 2, this.config.maxRetryDelayMs); this.logger.debug("Retry failed", { postId: error.postId, attempt, error: error.error, nextDelay: delay, }); } } return { success: false, error }; } /** * Check if an error is retryable */ private isRetryableError(error: unknown): boolean { if (!error) return false; const errorMessage = error instanceof Error ? error.message : String(error); // Network errors are retryable if ( errorMessage.includes("ECONNREFUSED") || errorMessage.includes("timeout") || errorMessage.includes("503") || errorMessage.includes("502") || errorMessage.includes("504") ) { return true; } // Authentication errors are not retryable if (errorMessage.includes("401") || errorMessage.includes("403")) { return false; } // Not found errors are not retryable if (errorMessage.includes("404")) { return false; } // Rate limiting is retryable if (errorMessage.includes("429")) { return true; } // Default to non-retryable for safety return false; } /** * Create batches from an array of items */ private createBatches<T>(items: T[], batchSize: number): T[][] { const batches: T[][] = []; for (let i = 0; i < items.length; i += batchSize) { batches.push(items.slice(i, i + batchSize)); } return batches; } /** * Promise-based delay utility */ private delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get current configuration */ getConfig(): BulkOperationConfig { return { ...this.config }; } /** * Update configuration */ updateConfig(config: Partial<BulkOperationConfig>): void { this.config = { ...this.config, ...config }; this.logger.debug("Configuration updated", { config: this.config }); } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/docdyhr/mcp-wordpress'

If you have feedback or need assistance with the MCP directory API, please join our Discord server