Files
WPSidekick/internal/wordpress/selector.go
T
2026-01-25 08:57:55 +01:00

284 lines
7.0 KiB
Go

package wordpress
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"seo-optimizer/internal/logger"
)
// PostSelector handles intelligent post selection for optimization
type PostSelector struct {
client *Client
logger *logger.Logger
}
// NewPostSelector creates a new post selector
func NewPostSelector(client *Client, log *logger.Logger) *PostSelector {
if log == nil {
log = logger.NewDefaultLogger()
}
return &PostSelector{
client: client,
logger: log.WithComponent("selector"),
}
}
// SelectNextPost returns the oldest modified post that meets selection criteria
// Returns nil if no posts meet the criteria
func (s *PostSelector) SelectNextPost(ctx context.Context, criteria SelectionCriteria) (*Post, error) {
s.logger.Info("Selecting next post for optimization",
"language", criteria.Language,
"min_words", criteria.MinContentLength,
"min_age_days", criteria.MinAgeDays,
"max_age_days", criteria.MaxAgeDays,
"cooldown_days", criteria.CooldownDays,
)
// Calculate cutoff dates
maxAgeDate := time.Now().AddDate(0, 0, -criteria.MaxAgeDays)
minAgeDate := time.Now().AddDate(0, 0, -criteria.MinAgeDays)
// Fetch published posts ordered by modified date (oldest first)
// Only get posts that are older than minAgeDays but not older than maxAgeDays
params := ListParams{
Lang: criteria.Language,
Status: "publish",
OrderBy: "modified",
Order: "asc",
PerPage: 50, // Check 50 posts at a time
Before: &minAgeDate,
After: &maxAgeDate,
}
page := 1
for {
params.Page = page
posts, err := s.client.ListPosts(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to list posts: %w", err)
}
if len(posts) == 0 {
s.logger.Info("No more posts to check")
break
}
s.logger.Debug("Checking posts batch",
"page", page,
"count", len(posts),
)
// Check each post against criteria
for _, post := range posts {
if s.meetsAllCriteria(ctx, post, criteria) {
s.logger.Info("Selected post for optimization",
"post_id", post.ID,
"title", post.Title.Rendered,
"modified", post.Modified,
"word_count", s.countWords(post.Content.Rendered),
)
return post, nil
}
}
// If we got less than per_page results, we're done
if len(posts) < params.PerPage {
break
}
page++
// Safety limit
if page > 20 {
s.logger.Warn("Reached page limit while selecting post", "pages", page)
break
}
}
s.logger.Info("No eligible posts found for optimization", "language", criteria.Language)
return nil, nil
}
// meetsAllCriteria checks if a post meets all selection criteria
func (s *PostSelector) meetsAllCriteria(ctx context.Context, post *Post, criteria SelectionCriteria) bool {
// Check minimum content length
wordCount := s.countWords(post.Content.Rendered)
if wordCount < criteria.MinContentLength {
s.logger.Debug("Post rejected: too short",
"post_id", post.ID,
"word_count", wordCount,
"minimum", criteria.MinContentLength,
)
return false
}
// Check if post was recently optimized (cooldown period)
if s.wasRecentlyOptimized(post, criteria.CooldownDays) {
s.logger.Debug("Post rejected: recently optimized",
"post_id", post.ID,
)
return false
}
// Check excluded categories
if len(criteria.ExcludeCategories) > 0 {
for _, categoryID := range post.Categories {
for _, excludedID := range criteria.ExcludeCategories {
if categoryID == excludedID {
s.logger.Debug("Post rejected: excluded category",
"post_id", post.ID,
"category_id", categoryID,
)
return false
}
}
}
}
return true
}
// countWords counts the number of words in HTML content
// Strips HTML tags before counting
func (s *PostSelector) countWords(htmlContent string) int {
// Remove HTML tags
text := s.stripHTML(htmlContent)
// Remove extra whitespace
text = strings.TrimSpace(text)
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Split by whitespace and count
if text == "" {
return 0
}
words := strings.Fields(text)
return len(words)
}
// stripHTML removes HTML tags from content
func (s *PostSelector) stripHTML(html string) string {
// Remove script and style content
html = regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`).ReplaceAllString(html, "")
html = regexp.MustCompile(`(?i)<style[^>]*>.*?</style>`).ReplaceAllString(html, "")
// Remove HTML comments
html = regexp.MustCompile(`<!--.*?-->`).ReplaceAllString(html, "")
// Remove all HTML tags
html = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(html, " ")
// Decode common HTML entities
html = strings.ReplaceAll(html, "&nbsp;", " ")
html = strings.ReplaceAll(html, "&amp;", "&")
html = strings.ReplaceAll(html, "&lt;", "<")
html = strings.ReplaceAll(html, "&gt;", ">")
html = strings.ReplaceAll(html, "&quot;", "\"")
html = strings.ReplaceAll(html, "&#39;", "'")
return html
}
// wasRecentlyOptimized checks if a post was optimized within the cooldown period
func (s *PostSelector) wasRecentlyOptimized(post *Post, cooldownDays int) bool {
if post.Meta == nil {
return false
}
optimizedAtValue, exists := post.Meta[MetaSEOOptimizedAt]
if !exists || optimizedAtValue == nil {
return false
}
// The value might be a float64 (JSON number) or int64
var optimizedAtUnix int64
switch v := optimizedAtValue.(type) {
case float64:
optimizedAtUnix = int64(v)
case int64:
optimizedAtUnix = v
case int:
optimizedAtUnix = int64(v)
case string:
// Try to parse as int if it's a string
fmt.Sscanf(v, "%d", &optimizedAtUnix)
default:
s.logger.Debug("Unknown type for optimization timestamp",
"post_id", post.ID,
"type", fmt.Sprintf("%T", v),
)
return false
}
if optimizedAtUnix == 0 {
return false
}
optimizedAt := time.Unix(optimizedAtUnix, 0)
cooldownUntil := optimizedAt.AddDate(0, 0, cooldownDays)
if time.Now().Before(cooldownUntil) {
s.logger.Debug("Post in cooldown period",
"post_id", post.ID,
"optimized_at", optimizedAt,
"cooldown_until", cooldownUntil,
)
return true
}
return false
}
// MarkAsOptimized updates the post meta to record the optimization timestamp
func (s *PostSelector) MarkAsOptimized(ctx context.Context, postID int) error {
now := time.Now().Unix()
if err := s.client.UpdatePostMeta(ctx, postID, MetaSEOOptimizedAt, now); err != nil {
return fmt.Errorf("failed to mark post as optimized: %w", err)
}
s.logger.Info("Marked post as optimized",
"post_id", postID,
"timestamp", now,
)
return nil
}
// GetOptimizationTimestamp returns when a post was last optimized
func (s *PostSelector) GetOptimizationTimestamp(ctx context.Context, postID int) (*time.Time, error) {
value, err := s.client.GetPostMeta(ctx, postID, MetaSEOOptimizedAt)
if err != nil {
return nil, err
}
if value == nil {
return nil, nil
}
var optimizedAtUnix int64
switch v := value.(type) {
case float64:
optimizedAtUnix = int64(v)
case int64:
optimizedAtUnix = v
case int:
optimizedAtUnix = int64(v)
default:
return nil, fmt.Errorf("unexpected type for optimization timestamp: %T", v)
}
if optimizedAtUnix == 0 {
return nil, nil
}
timestamp := time.Unix(optimizedAtUnix, 0)
return &timestamp, nil
}