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:
- Navigate to Admin Mode > Admin tab.
- Select Scheduled Jobs.
- Locate "Blog SEO Enrichment Job" and configure the desired schedule (e.g., Weekly, or Daily off-peak hours).
- 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
RateLimitDelayMsconstant inBlogSeoEnrichmentJob.csis sufficient (we used 30 seconds). If the job volume is high, consider increasing the delay or implementing a more sophisticated backoff retry mechanism within theIGinbokAIServiceimplementation.
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 (usingIContentVersionRepositorymight 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.Publishis used correctly. The primary check for long-running jobs is guaranteeing thatIsStopSignalledis checked frequently within the loop structure, which we implemented at the start of theforeachloop.