AI & Automation

Optimizely: Automating SEO Metadata with AI Scheduled Jobs

By Ginbok5 min read

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

Issue: The Vietnamese branch is not being enriched or saved

Issue: Infinite loop or job hangs

#OptimizelyCMS#ScheduledJobs#AIService#SEOAutomation#DotNet8
← Back to Articles