Optimizely: Automating SEO Metadata with AI Scheduled Jobs

Maintaining high-quality SEO metadata across hundreds or thousands of content pages in a multi-language Optimizely installation can be a significant editorial burden. Content editors often overlook filling in less critical fields like Meta Keywords or Blog Tags, leading to inconsistent search performance.

This tutorial details how to leverage Optimizely's scheduled job framework alongside an internal AI service (e.g., GPT via Azure OpenAI or similar) to automatically audit and enrich missing SEO properties in both the master (English) and secondary (Vietnamese) content branches.

Prerequisites and Setup

We assume you have a standard .NET 8 Optimizely CMS 12 solution and an existing AI integration service defined by the interface IGinbokAIService.

1. Extending the AI Service Interface

To specifically handle SEO generation, we define a dedicated contract that returns structured SEO data, ensuring the AI model focuses only on the required properties rather than generating a full content draft.

// MyProject.Model/Services/IGinbokAIService.cs
    public interface IGinbokAIService
    {
        Task<SeoData> GenerateSeoMetadataAsync(string contentBody, string languageCode);
    }

    public class SeoData
    {
        public string MetaTitle { get; set; }
        public string MetaDescription { get; set; }
        public string MetaKeywords { get; set; }
        public string BlogCategory { get; set; }
        public string BlogTags { get; set; }
    }

2. Identifying the Content Model

The job targets BlogDetailPage objects, which must have the necessary SEO properties defined as CultureSpecific to support branching.

// MyProject.Model/Content/BlogDetailPage.cs
    [ContentType(DisplayName = "Blog Detail Page", GUID = "5D24D857-...", GroupName = "Blog")]
    public class BlogDetailPage : PageData
    {
        [CultureSpecific]
        [Display(Name = "Page Content", GroupName = SystemTabNames.Content)]
        public virtual XhtmlString MainBody { get; set; }

        [CultureSpecific]
        [Display(Name = "Meta Title (SEO)", GroupName = SystemTabNames.SEO)]
        public virtual string MetaTitle { get; set; }
        
        // ... MetaDescription, MetaKeywords, BlogCategory, BlogTags properties follow the same pattern
    }

Creating the BlogSeoEnrichmentJob

The scheduled job must inherit from ScheduledJobBase and be configured to support stopping, as AI API calls can be lengthy.

// MyProject.Web/ScheduledJobs/BlogSeoEnrichmentJob.cs
    using EPiServer.PlugIn;
    using EPiServer.Scheduler;
    using EPiServer.ServiceLocation;
    using EPiServer.Core;
    using EPiServer.DataAbstraction;
    using EPiServer.DataAccess;
    using EPiServer.Globalization;
    using System.Threading.Tasks;

    [ScheduledPlugIn(DisplayName = "Blog SEO Enrichment Job", 
                     Default = false, 
                     SortIndex = 100, 
                     IsStoppable = true)]
    public class BlogSeoEnrichmentJob : ScheduledJobBase
    {
        private readonly IContentLoader _contentLoader;
        private readonly IContentRepository _contentRepository;
        private readonly IGinbokAIService _aiService;
        
        private const int RateLimitDelayMs = 30000; // 30 seconds delay between AI calls

        public BlogSeoEnrichmentJob(IContentLoader contentLoader, IContentRepository contentRepository, IGinbokAIService aiService) : base(contentLoader)
        {
            _contentLoader = contentLoader;
            _contentRepository = contentRepository;
            _aiService = aiService;
            Is  StopSignalled = false;
        }

        public override string Execute()
        {
            Set		Status("Starting Blog SEO enrichment job...");
            var blogRoot = ContentReference.StartPage; // Replace with actual Blog List root reference
            var blogs = _contentLoader.GetChildren<BlogDetailPage>(blogRoot);
            int pagesProcessed = 0;

            foreach (var blogPage in blogs)
            {
                if (IsStopSignalled)
                {
                    return "Job stopped manually.";
                }

                // 1. Process Master Language (English)
                Task.WaitAll(ProcessLanguageBranch(blogPage.ContentLink, "en", true));
                
                // 2. Process Vietnamese Language (Vi)
                Task.WaitAll(ProcessLanguageBranch(blogPage.ContentLink, "vi", false));

                pagesProcessed++;
                SetStatus($"Processed {pagesProcessed} blogs. Last: {blogPage.Name}. Waiting for rate limit...");
                
                // Respect rate limiting constraint
                Task.Delay(RateLimitDelayMs).Wait(); 
            }

            return $"Job finished. Successfully processed {pagesProcessed} blogs.";
        }
        
        // Signal the job to stop gracefully
        public override void Stop()
        {
            IsStopSignalled = true;
        }

        private async Task ProcessLanguageBranch(ContentReference contentLink, string languageCode, bool isMaster)
        {
            try
            {
                // Ensure the content exists in the required language
                var culture = new System.Globalization.CultureInfo(languageCode);
                if (!_contentLoader.TryGet<BlogDetailPage>(contentLink, culture, out var page))
                {
                    if (!isMaster) return; // Skip non-existent non-master branches
                    page = _contentLoader.Get<BlogDetailPage>(contentLink); // Fallback to master if needed
                }
                
                // Create a writable clone
                var clone = (BlogDetailPage)page.CreateWritableClone();
                bool needsSave = false;
                
                // Check if any required field is missing
                if (string.IsNullOrEmpty(clone.MetaTitle) || string.IsNullOrEmpty(clone.BlogTags))
                {
                    // Use the content from the current branch for AI input
                    string contentBody = clone.MainBody?.ToHtmlString() ?? string.Empty;

                    if (!string.IsNullOrEmpty(contentBody))
                    {
                        var seoData = await _aiService.GenerateSeoMetadataAsync(contentBody, languageCode);

                        if (string.IsNullOrEmpty(clone.MetaTitle))
                        {
                            clone.MetaTitle = seoData.MetaTitle;
                            needsSave = true;
                        }
                        // Apply other missing fields
                        if (string.IsNullOrEmpty(clone.MetaDescription)) clone.MetaDescription = seoData.MetaDescription;
                        if (string.IsNullOrEmpty(clone.MetaKeywords)) clone.MetaKeywords = seoData.MetaKeywords;
                        if (string.IsNullOrEmpty(clone.BlogCategory)) clone.BlogCategory = seoData.BlogCategory;
                        if (string.IsNullOrEmpty(clone.BlogTags)) clone.BlogTags = seoData.BlogTags;

                        needsSave = true;
                    }
                }

                if (needsSave)
                {
                    _contentRepository.Save(clone, SaveAction.Publish, AccessLevel.NoAccess);
                    ReportProgress(1, 1, $"Saved changes for {clone.Name} ({languageCode}).");
                }
            }
            catch (Exception ex)
            {
                ReportProgress(0, 1, $"Error processing page {contentLink} ({languageCode}): {ex.Message}");
                // Important: Catching allows the job to continue with the next page
            }
        }
    }

Deployment and Configuration

After compiling and deploying BlogSeoEnrichmentJob.cs to MyProject.Web/bin, the job will appear in the Optimizely Admin interface:

  1. Navigate to Admin Mode > Admin tab.
  2. Select Scheduled Jobs.
  3. Locate "Blog SEO Enrichment Job" and configure the desired schedule (e.g., Weekly, or Daily off-peak hours).
  4. Ensure the Identity running the job has necessary Publish permissions on the content tree.

Troubleshooting Common Issues

Issue: AI calls are failing with 429 Too Many Requests

  • Cause: The job exceeded the provisioned throughput or rate limit defined by the external AI API (e.g., Azure OpenAI tokens per minute).
  • Solution: Verify the RateLimitDelayMs constant in BlogSeoEnrichmentJob.cs is sufficient (we used 30 seconds). If the job volume is high, consider increasing the delay or implementing a more sophisticated backoff retry mechanism within the IGinbokAIService implementation.

Issue: The Vietnamese branch is not being enriched or saved

  • Cause: The job failed to retrieve the localized content branch, or the content was not checked out/published in Vietnamese.
  • Solution: Ensure _contentLoader.TryGet<BlogDetailPage>(contentLink, culture, out var page) correctly handles content retrieval for unpublished localized versions if necessary (using IContentVersionRepository might be required if unpublished drafts need enrichment, though typically scheduled jobs focus on published content). Verify the Vietnamese branch actually exists before attempting enrichment.

Issue: Infinite loop or job hangs

  • Cause: If content saving triggers a chained event (such as another scheduled task or an indexing job) that is too resource-intensive, the job might appear to hang or fail to save.
  • Solution: Ensure SaveAction.Publish is used correctly. The primary check for long-running jobs is guaranteeing that IsStopSignalled is checked frequently within the loop structure, which we implemented at the start of the foreach loop.
← Quay lại Blog