Files
alexandrev-tibco 4f5ab09b66 +1
2026-02-02 17:43:53 +01:00

664 lines
18 KiB
Go

package seo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
// Optimizer coordinates the entire SEO optimization workflow
type Optimizer struct {
wpClient *wordpress.Client
llmClient agent.LLMClient
llmProvider string
contentBuilder *ContentBuilder
selector *wordpress.PostSelector
storage *storage.OptimizationStorage
config *config.Config
logger *logger.Logger
}
// OptimizeRunOptions defines optional behavior for optimization runs.
type OptimizeRunOptions struct {
CheckCooldown bool
Feedback string
}
// SkipError indicates a deliberate skip (not a failure).
type SkipError struct {
Reason string
}
func (e *SkipError) Error() string {
return e.Reason
}
// NewOptimizer creates a new optimizer instance
func NewOptimizer(
wpClient *wordpress.Client,
llmClient agent.LLMClient,
llmProvider string,
store *storage.OptimizationStorage,
cfg *config.Config,
log *logger.Logger,
) *Optimizer {
if log == nil {
log = logger.NewDefaultLogger()
}
return &Optimizer{
wpClient: wpClient,
llmClient: llmClient,
llmProvider: llmProvider,
contentBuilder: NewContentBuilder(log),
selector: wordpress.NewPostSelector(wpClient, log),
storage: store,
config: cfg,
logger: log.WithComponent("optimizer"),
}
}
// OptimizeNextPost executes the full optimization cycle for the next eligible post
func (o *Optimizer) OptimizeNextPost(ctx context.Context, language string) error {
o.logger.Info("Starting optimization cycle", "language", language)
if o.hasPendingDrafts() {
reason := "pending drafts exist"
o.logger.Info("Skipping optimization", "reason", reason)
return &SkipError{Reason: reason}
}
// 1. Select next post
criteria := wordpress.SelectionCriteria{
Language: language,
MinContentLength: o.config.Optimization.MinContentLength,
MinAgeDays: o.config.Optimization.MinAgeDays,
MaxAgeDays: o.config.Optimization.MaxAgeDays,
CooldownDays: o.config.Optimization.CooldownDays,
}
post, err := o.selector.SelectNextPost(ctx, criteria)
if err != nil {
return fmt.Errorf("post selection failed: %w", err)
}
if post == nil {
o.logger.Info("No eligible posts found for optimization", "language", language)
return nil
}
o.logger.Info("Selected post for optimization",
"post_id", post.ID,
"title", post.Title.Rendered,
"slug", post.Slug,
"modified", post.Modified,
)
err = o.optimizePost(ctx, post, language, &OptimizeRunOptions{
CheckCooldown: false,
})
var skipErr *SkipError
if err != nil && !errors.As(err, &skipErr) {
return err
}
if o.config.Optimization.SyncTranslations {
if err := o.optimizeTranslationPosts(ctx, post, language); err != nil {
return err
}
}
return nil
}
// OptimizeSpecificPost allows manual optimization of a specific post via API
func (o *Optimizer) OptimizeSpecificPost(ctx context.Context, postID int, language string) error {
o.logger.Info("Starting specific post optimization",
"post_id", postID,
"language", language,
)
if o.hasPendingDrafts() {
reason := "pending drafts exist"
o.logger.Info("Skipping optimization", "post_id", postID, "reason", reason)
return &SkipError{Reason: reason}
}
// 1. Fetch the specific post
post, err := o.wpClient.GetPost(ctx, postID, language)
if err != nil {
return fmt.Errorf("failed to fetch post: %w", err)
}
o.logger.Info("Retrieved post",
"title", post.Title.Rendered,
"slug", post.Slug,
)
err = o.optimizePost(ctx, post, language, &OptimizeRunOptions{
CheckCooldown: true,
})
var skipErr *SkipError
if err != nil && !errors.As(err, &skipErr) {
return err
}
if o.config.Optimization.SyncTranslations {
if err := o.optimizeTranslationPosts(ctx, post, language); err != nil {
return err
}
}
return err
}
// OptimizeSpecificPostWithFeedback regenerates a draft using explicit feedback.
func (o *Optimizer) OptimizeSpecificPostWithFeedback(ctx context.Context, postID int, language, feedback string) error {
o.logger.Info("Starting feedback optimization",
"post_id", postID,
"language", language,
)
post, err := o.wpClient.GetPost(ctx, postID, language)
if err != nil {
return fmt.Errorf("failed to fetch post: %w", err)
}
return o.optimizePost(ctx, post, language, &OptimizeRunOptions{
CheckCooldown: false,
Feedback: feedback,
})
}
func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, language string, options *OptimizeRunOptions) error {
if post == nil {
return fmt.Errorf("post is nil")
}
effectiveLanguage := language
if effectiveLanguage == "" {
effectiveLanguage = post.Lang
}
if post.Status != "publish" {
return fmt.Errorf("post is not published (status: %s)", post.Status)
}
checkCooldown := options == nil || options.CheckCooldown
if checkCooldown && o.wasRecentlyOptimized(post, o.config.Optimization.CooldownDays) {
reason := "post optimized within cooldown window"
o.logger.Info("Skipping optimization",
"post_id", post.ID,
"title", post.Title.Rendered,
"slug", post.Slug,
"reason", reason,
)
if err := o.saveSkippedRecord(post, effectiveLanguage, "recently_optimized", nil); err != nil {
o.logger.Warn("Failed to save skipped optimization record", "error", err)
}
return &SkipError{Reason: reason}
}
startTime := time.Now()
// 2. Gather context for the LLM
internalPosts, err := o.wpClient.GetInternalPosts(ctx, effectiveLanguage)
if err != nil {
return fmt.Errorf("failed to fetch internal posts: %w", err)
}
o.logger.Debug("Retrieved internal posts for linking",
"count", len(internalPosts),
)
// Get categories and tags for context
categories, err := o.wpClient.GetCategories(ctx)
if err != nil {
o.logger.Warn("Failed to fetch categories", "error", err)
categories = []wordpress.Category{}
}
tags, err := o.wpClient.GetTags(ctx)
if err != nil {
o.logger.Warn("Failed to fetch tags", "error", err)
tags = []wordpress.Tag{}
}
// Build category and tag maps
categoryMap := make(map[int]string)
for _, cat := range categories {
categoryMap[cat.ID] = cat.Name
}
tagMap := make(map[int]string)
for _, tag := range tags {
tagMap[tag.ID] = tag.Name
}
// 3. Call LLM for optimization
o.logger.Info("Calling LLM for optimization",
"provider", o.llmProvider,
"post_id", post.ID,
"title", post.Title.Rendered,
"slug", post.Slug,
)
feedback := ""
if options != nil {
feedback = options.Feedback
}
optimization, err := o.llmClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap, &agent.OptimizeOptions{
Feedback: feedback,
Profile: o.config.Profile,
})
if err != nil {
return fmt.Errorf("llm optimization failed: %w", err)
}
// Skip if the post is too recent and no modifications were required
if o.shouldSkipRecentNoChanges(post, optimization) {
reason := "post too recent and no changes required"
o.logger.Info("Skipping optimization",
"post_id", post.ID,
"title", post.Title.Rendered,
"slug", post.Slug,
"reason", reason,
)
if err := o.saveSkippedRecord(post, effectiveLanguage, "no_changes_recent", optimization); err != nil {
o.logger.Warn("Failed to save skipped optimization record", "error", err)
}
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
o.logger.Warn("Failed to mark post as optimized", "error", err)
}
return &SkipError{Reason: reason}
}
// 4. Build optimized content
o.logger.Info("Building optimized content")
optimizedContent, err := o.contentBuilder.BuildOptimizedContent(
post.Content.Rendered,
optimization,
)
if err != nil {
return fmt.Errorf("content building failed: %w", err)
}
// 5. Create draft revision in WordPress
if o.config.Runtime.DryRun {
o.logger.Info("Dry-run enabled, skipping WordPress draft creation")
} else {
o.logger.Info("Creating draft revision in WordPress")
}
updates := wordpress.PostUpdate{
Status: "draft",
Title: optimization.SEOMeta.Title,
Content: optimizedContent,
Excerpt: optimization.SEOMeta.Description,
Meta: map[string]interface{}{
// Store SEO meta fields (RankMath)
wordpress.MetaRankMathTitle: optimization.SEOMeta.Title,
wordpress.MetaRankMathDescription: optimization.SEOMeta.Description,
// Store optimization metadata
wordpress.MetaSEOOptimizedAt: time.Now().Unix(),
wordpress.MetaSEOOptimizationSummary: optimization.Summary,
// Store full optimization data for reference
wordpress.MetaSEOOptimizationData: o.serializeOptimizationData(optimization),
},
}
draftPostID := 0
if !o.config.Runtime.DryRun {
var err error
draftPostID, err = o.wpClient.CreateDraftRevision(ctx, post.ID, updates)
if err != nil {
return fmt.Errorf("failed to create draft revision: %w", err)
}
o.logger.Info("Draft revision created",
"draft_post_id", draftPostID,
"original_post_id", post.ID,
)
}
// 6. Save optimization record to storage
record := &storage.OptimizationRecord{
PostID: post.ID,
OriginalPostID: post.ID,
DraftPostID: draftPostID,
PostTitle: post.Title.Rendered,
Language: effectiveLanguage,
OptimizedAt: time.Now(),
Status: "pending",
Optimization: optimization,
}
if err := o.storage.Save(record); err != nil {
o.logger.Error("Failed to save optimization record", "error", err)
}
// 7. Mark as optimized (prevent re-optimization too soon)
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
o.logger.Warn("Failed to mark post as optimized", "error", err)
}
duration := time.Since(startTime)
// 8. Log success with summary
o.logger.Info("Optimization completed successfully",
"post_id", post.ID,
"title", post.Title.Rendered,
"language", effectiveLanguage,
"duration", duration,
"changes", len(optimization.ContentOptimization.Changes),
"internal_links", len(optimization.ContentOptimization.InternalLinks),
"faq_created", optimization.FAQBlock.ShouldCreate,
"faq_questions", len(optimization.FAQBlock.Questions),
"fact_checks", len(optimization.Validation.FactChecks),
"broken_links_fixed", len(optimization.Validation.BrokenLinks),
)
o.logger.Info("Optimization summary",
"post_id", post.ID,
"summary", optimization.Summary,
)
// 9. Send webhook notification (if configured)
if o.config.Notifications.WebhookURL != "" {
go o.sendWebhookNotification(post, optimization, duration)
}
return nil
}
func (o *Optimizer) optimizeTranslationPosts(ctx context.Context, basePost *wordpress.Post, baseLanguage string) error {
targets := o.translationTargets(basePost, baseLanguage)
if len(targets) == 0 {
return nil
}
o.logger.Info("Optimizing translation posts",
"post_id", basePost.ID,
"count", len(targets),
)
var errs []error
for _, target := range targets {
translationPost, err := o.wpClient.GetPost(ctx, target.ID, target.Language)
if err != nil {
errs = append(errs, fmt.Errorf("failed to fetch translation post %d (%s): %w", target.ID, target.Language, err))
continue
}
if err := o.optimizePost(ctx, translationPost, target.Language, &OptimizeRunOptions{
CheckCooldown: true,
}); err != nil {
var skipErr *SkipError
if errors.As(err, &skipErr) {
continue
}
errs = append(errs, fmt.Errorf("translation post %d (%s): %w", target.ID, target.Language, err))
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
type translationTarget struct {
Language string
ID int
}
func (o *Optimizer) translationTargets(post *wordpress.Post, currentLanguage string) []translationTarget {
if post == nil || len(post.Translations) == 0 {
return nil
}
if currentLanguage == "" {
currentLanguage = post.Lang
}
var targets []translationTarget
for lang, id := range post.Translations {
if id == 0 || id == post.ID {
continue
}
if currentLanguage != "" && lang == currentLanguage {
continue
}
targets = append(targets, translationTarget{
Language: lang,
ID: id,
})
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].Language < targets[j].Language
})
return targets
}
func (o *Optimizer) SyncTranslationsEnabled() bool {
if o == nil || o.config == nil {
return false
}
return o.config.Optimization.SyncTranslations
}
// IsDryRun returns whether the optimizer is running in dry-run mode.
func (o *Optimizer) IsDryRun() bool {
if o == nil || o.config == nil {
return false
}
return o.config.Runtime.DryRun
}
func (o *Optimizer) hasPendingDrafts() bool {
if o == nil || o.storage == nil {
return false
}
records, err := o.storage.ListPending()
if err != nil {
o.logger.Warn("Failed to list pending drafts", "error", err)
return false
}
return len(records) > 0
}
func (o *Optimizer) shouldSkipRecentNoChanges(post *wordpress.Post, optimization *models.OptimizationResponse) bool {
if o.config.Optimization.MinAgeDays <= 0 {
return false
}
postTime := o.getPostModifiedTime(post)
if postTime.IsZero() {
return false
}
minAgeCutoff := time.Now().AddDate(0, 0, -o.config.Optimization.MinAgeDays)
if postTime.Before(minAgeCutoff) {
return false
}
return o.isNoChangeOptimization(post, optimization)
}
func (o *Optimizer) isNoChangeOptimization(post *wordpress.Post, optimization *models.OptimizationResponse) bool {
if optimization == nil {
return false
}
changesEmpty := len(optimization.ContentOptimization.Changes) == 0
linksEmpty := len(optimization.ContentOptimization.InternalLinks) == 0
faqEmpty := !optimization.FAQBlock.ShouldCreate || len(optimization.FAQBlock.Questions) == 0
currentTitle := o.getMetaString(post.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
if currentTitle == "" {
currentTitle = post.Title.Rendered
}
currentDescription := o.getMetaString(post.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
metaUnchanged := strings.TrimSpace(optimization.SEOMeta.Title) == strings.TrimSpace(currentTitle) &&
strings.TrimSpace(optimization.SEOMeta.Description) == strings.TrimSpace(currentDescription)
return changesEmpty && linksEmpty && faqEmpty && metaUnchanged
}
func (o *Optimizer) getMetaString(meta map[string]interface{}, keys ...string) string {
if meta == nil {
return ""
}
for _, key := range keys {
if value, ok := meta[key]; ok && value != nil {
if str, ok := value.(string); ok && str != "" {
return str
}
}
}
return ""
}
func (o *Optimizer) getPostModifiedTime(post *wordpress.Post) time.Time {
if post == nil {
return time.Time{}
}
if !post.Modified.Time.IsZero() {
return post.Modified.Time
}
if !post.Date.Time.IsZero() {
return post.Date.Time
}
return time.Time{}
}
func (o *Optimizer) wasRecentlyOptimized(post *wordpress.Post, cooldownDays int) bool {
if post == nil || post.Meta == nil || cooldownDays <= 0 {
return false
}
optimizedAtValue, exists := post.Meta[wordpress.MetaSEOOptimizedAt]
if !exists || optimizedAtValue == nil {
return false
}
var optimizedAtUnix int64
switch v := optimizedAtValue.(type) {
case float64:
optimizedAtUnix = int64(v)
case int64:
optimizedAtUnix = v
case int:
optimizedAtUnix = int64(v)
case string:
fmt.Sscanf(v, "%d", &optimizedAtUnix)
default:
return false
}
if optimizedAtUnix == 0 {
return false
}
optimizedAt := time.Unix(optimizedAtUnix, 0)
cooldownUntil := optimizedAt.AddDate(0, 0, cooldownDays)
return time.Now().Before(cooldownUntil)
}
func (o *Optimizer) saveSkippedRecord(
post *wordpress.Post,
language string,
reason string,
optimization *models.OptimizationResponse,
) error {
if post == nil {
return fmt.Errorf("post is nil")
}
record := &storage.OptimizationRecord{
PostID: post.ID,
OriginalPostID: post.ID,
DraftPostID: 0,
PostTitle: post.Title.Rendered,
Language: language,
OptimizedAt: time.Now(),
Status: "skipped",
Optimization: optimization,
SkipReason: reason,
}
return o.storage.Save(record)
}
// serializeOptimizationData converts optimization response to JSON string for storage
func (o *Optimizer) serializeOptimizationData(optimization *models.OptimizationResponse) string {
data, err := json.Marshal(optimization)
if err != nil {
o.logger.Error("Failed to serialize optimization data", "error", err)
return ""
}
return string(data)
}
// sendWebhookNotification sends a notification webhook with optimization results
func (o *Optimizer) sendWebhookNotification(
post *wordpress.Post,
optimization *models.OptimizationResponse,
duration time.Duration,
) {
payload := map[string]interface{}{
"post_id": post.ID,
"title": post.Title.Rendered,
"slug": post.Slug,
"link": post.Link,
"language": post.Lang,
"seo_title": optimization.SEOMeta.Title,
"seo_desc": optimization.SEOMeta.Description,
"changes": len(optimization.ContentOptimization.Changes),
"links": len(optimization.ContentOptimization.InternalLinks),
"faq_created": optimization.FAQBlock.ShouldCreate,
"faq_questions": len(optimization.FAQBlock.Questions),
"summary": optimization.Summary,
"duration_seconds": duration.Seconds(),
"timestamp": time.Now().Format(time.RFC3339),
}
jsonData, err := json.Marshal(payload)
if err != nil {
o.logger.Error("Failed to marshal webhook payload", "error", err)
return
}
resp, err := http.Post(o.config.Notifications.WebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
o.logger.Error("Failed to send webhook", "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
o.logger.Debug("Webhook sent successfully", "status", resp.StatusCode)
} else {
o.logger.Warn("Webhook returned non-2xx status", "status", resp.StatusCode)
}
}