284 lines
7.0 KiB
Go
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, " ", " ")
|
|
html = strings.ReplaceAll(html, "&", "&")
|
|
html = strings.ReplaceAll(html, "<", "<")
|
|
html = strings.ReplaceAll(html, ">", ">")
|
|
html = strings.ReplaceAll(html, """, "\"")
|
|
html = strings.ReplaceAll(html, "'", "'")
|
|
|
|
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 ×tamp, nil
|
|
}
|