Files
WPSidekick/internal/ideas/service.go
T
alexandrev-tibco 3a66d687e1 Minor updates
2026-02-20 16:21:18 +01:00

648 lines
16 KiB
Go

package ideas
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/reddit"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
// Service orchestrates idea generation and drafting.
type Service struct {
wpClient *wordpress.Client
llm agent.LLMClient
reddit *reddit.Client
store *storage.IdeaStorage
outreach *storage.OutreachStorage
cfg config.IdeasConfig
outreachCfg config.OutreachConfig
profile config.SiteProfile
languages []string
dryRun bool
logger *logger.Logger
}
func NewService(
wpClient *wordpress.Client,
llm agent.LLMClient,
redditClient *reddit.Client,
store *storage.IdeaStorage,
cfg config.IdeasConfig,
outreachStore *storage.OutreachStorage,
outreachCfg config.OutreachConfig,
profile config.SiteProfile,
languages []string,
dryRun bool,
log *logger.Logger,
) *Service {
if log == nil {
log = logger.NewDefaultLogger()
}
if redditClient == nil {
redditClient = reddit.NewClient("", log)
}
return &Service{
wpClient: wpClient,
llm: llm,
reddit: redditClient,
store: store,
cfg: cfg,
outreach: outreachStore,
outreachCfg: outreachCfg,
profile: profile,
languages: normalizeLanguages(languages),
dryRun: dryRun,
logger: log.WithComponent("ideas"),
}
}
// GenerateDailyIdeas fetches Reddit posts and creates new idea records.
func (s *Service) GenerateDailyIdeas(ctx context.Context) ([]*models.PostIdea, error) {
if !s.cfg.Enabled {
return nil, fmt.Errorf("ideas are disabled")
}
posts, err := s.reddit.FetchPosts(ctx, s.cfg.Subreddit, s.cfg.Listing, s.cfg.TimeRange, s.cfg.MaxRedditPosts)
if err != nil {
return nil, err
}
filtered := make([]reddit.Post, 0, len(posts))
for _, post := range posts {
if post.Score < s.cfg.MinScore {
continue
}
if post.NumComments < s.cfg.MinComments {
continue
}
filtered = append(filtered, post)
}
if len(filtered) == 0 {
return nil, fmt.Errorf("no reddit posts met threshold (min_score=%d, min_comments=%d)", s.cfg.MinScore, s.cfg.MinComments)
}
styleSamples, err := s.fetchStyleSamples(ctx)
if err != nil {
s.logger.Warn("Failed to fetch style samples", "error", err)
}
promptData := IdeaPromptData{
Subreddit: s.cfg.Subreddit,
MaxIdeas: s.cfg.MaxIdeas,
StyleSamples: styleSamples,
SiteName: s.profile.Name,
SiteURL: s.profile.URL,
SiteTopics: strings.Join(s.profile.Topics, ", "),
SiteAudience: s.profile.Audience,
SiteVoice: s.profile.Voice,
}
for _, post := range filtered {
promptData.RedditPosts = append(promptData.RedditPosts, RedditPromptPost{
Title: post.Title,
URL: post.URL,
SelfText: truncate(post.SelfText, 800),
Author: post.Author,
Score: post.Score,
Comments: post.NumComments,
CreatedAt: time.Unix(post.CreatedUTC, 0).Format(time.RFC3339),
})
}
prompt, err := BuildIdeaPrompt(promptData)
if err != nil {
return nil, fmt.Errorf("failed to build idea prompt: %w", err)
}
ideas, err := s.llm.GeneratePostIdeas(ctx, prompt)
if err != nil {
return nil, err
}
if len(ideas) > s.cfg.MaxIdeas {
ideas = ideas[:s.cfg.MaxIdeas]
}
stored := make([]*models.PostIdea, 0, len(ideas))
for _, idea := range ideas {
idea.ID = uuid.New().String()
idea.Status = "new"
idea.CreatedAt = time.Now()
idea.UpdatedAt = time.Now()
if err := s.store.Save(idea); err != nil {
s.logger.Warn("Failed to save idea", "error", err, "title", idea.SEOTitle)
continue
}
stored = append(stored, idea)
}
s.logger.Info("Stored ideas", "count", len(stored))
return stored, nil
}
// GenerateOutreachSuggestions finds Reddit threads where an existing article could help.
func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.OutreachSuggestion, error) {
if s.outreach == nil {
return nil, fmt.Errorf("outreach storage not configured")
}
if !s.outreachCfg.Enabled {
return nil, fmt.Errorf("outreach is disabled")
}
cfg := s.outreachCfg
posts, err := s.reddit.FetchPosts(ctx, cfg.Subreddit, cfg.Listing, cfg.TimeRange, cfg.MaxRedditPosts)
if err != nil {
return nil, err
}
filtered := make([]reddit.Post, 0, len(posts))
seenCandidates := make(map[string]struct{}, len(posts))
for _, post := range posts {
if post.Score < cfg.MinScore {
continue
}
if post.NumComments < cfg.MinComments {
continue
}
link := post.PermalinkURL
if link == "" {
link = post.URL
}
key := canonicalRedditURL(link)
if key == "" {
continue
}
if _, exists := seenCandidates[key]; exists {
continue
}
seenCandidates[key] = struct{}{}
filtered = append(filtered, post)
}
if len(filtered) == 0 {
return nil, fmt.Errorf("no reddit posts met outreach threshold (min_score=%d, min_comments=%d)", cfg.MinScore, cfg.MinComments)
}
existingSuggestions, err := s.outreach.List("all")
if err != nil {
return nil, fmt.Errorf("failed to list existing outreach suggestions: %w", err)
}
existingByRedditURL := make(map[string]struct{}, len(existingSuggestions))
for _, item := range existingSuggestions {
key := canonicalRedditURL(item.RedditURL)
if key == "" {
continue
}
existingByRedditURL[key] = struct{}{}
}
candidatePosts := make([]reddit.Post, 0, len(filtered))
for _, post := range filtered {
link := post.PermalinkURL
if link == "" {
link = post.URL
}
key := canonicalRedditURL(link)
if key == "" {
continue
}
if _, exists := existingByRedditURL[key]; exists {
continue
}
candidatePosts = append(candidatePosts, post)
}
if len(candidatePosts) == 0 {
return nil, fmt.Errorf("no new outreach suggestions: all candidate reddit links were already suggested")
}
articles, err := s.wpClient.GetInternalPosts(ctx, "en")
if err != nil {
return nil, fmt.Errorf("failed to fetch internal posts: %w", err)
}
if cfg.MaxArticles > 0 && len(articles) > cfg.MaxArticles {
articles = articles[:cfg.MaxArticles]
}
articleRefs := make([]ArticleRef, 0, len(articles))
for _, article := range articles {
articleRefs = append(articleRefs, ArticleRef{
Title: strings.TrimSpace(article.Title),
URL: article.Link,
Excerpt: truncate(stripHTML(article.Excerpt), 220),
})
}
promptData := OutreachPromptData{
Subreddit: cfg.Subreddit,
MaxSuggestions: cfg.MaxSuggestions,
Articles: articleRefs,
}
for _, post := range candidatePosts {
link := post.PermalinkURL
if link == "" {
link = post.URL
}
promptData.RedditPosts = append(promptData.RedditPosts, RedditPromptPost{
Title: post.Title,
URL: link,
SelfText: truncate(post.SelfText, 800),
Author: post.Author,
Score: post.Score,
Comments: post.NumComments,
CreatedAt: time.Unix(post.CreatedUTC, 0).Format(time.RFC3339),
})
}
prompt, err := BuildOutreachPrompt(promptData)
if err != nil {
return nil, fmt.Errorf("failed to build outreach prompt: %w", err)
}
suggestions, err := s.llm.GenerateOutreachSuggestions(ctx, prompt)
if err != nil {
return nil, err
}
if len(suggestions) > cfg.MaxSuggestions {
suggestions = suggestions[:cfg.MaxSuggestions]
}
stored := make([]*models.OutreachSuggestion, 0, len(suggestions))
seenGenerated := make(map[string]struct{}, len(suggestions))
for _, suggestion := range suggestions {
key := canonicalRedditURL(suggestion.RedditURL)
if key == "" {
continue
}
if _, exists := existingByRedditURL[key]; exists {
continue
}
if _, exists := seenGenerated[key]; exists {
continue
}
seenGenerated[key] = struct{}{}
suggestion.RedditURL = key
suggestion.ID = uuid.New().String()
suggestion.Status = "new"
suggestion.CreatedAt = time.Now()
suggestion.UpdatedAt = time.Now()
if err := s.outreach.Save(suggestion); err != nil {
s.logger.Warn("Failed to save outreach suggestion", "error", err, "reddit_url", suggestion.RedditURL)
continue
}
stored = append(stored, suggestion)
}
s.logger.Info("Stored outreach suggestions", "count", len(stored))
return stored, nil
}
// ListOutreach returns outreach suggestions by status.
func (s *Service) ListOutreach(status string) ([]*models.OutreachSuggestion, error) {
if s.outreach == nil {
return nil, fmt.Errorf("outreach storage not configured")
}
if status == "" {
status = "new"
}
return s.outreach.List(status)
}
// UpdateOutreachStatus changes the status for an outreach suggestion.
func (s *Service) UpdateOutreachStatus(id, status string) (*models.OutreachSuggestion, error) {
if s.outreach == nil {
return nil, fmt.Errorf("outreach storage not configured")
}
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "new", "drafted", "reviewed", "dismissed":
default:
return nil, fmt.Errorf("invalid outreach status: %s", status)
}
return s.outreach.UpdateStatus(id, status)
}
// DeleteOutreach removes an outreach suggestion.
func (s *Service) DeleteOutreach(id string) error {
if s.outreach == nil {
return fmt.Errorf("outreach storage not configured")
}
return s.outreach.Delete(id)
}
// ListIdeas returns ideas by status.
func (s *Service) ListIdeas(status string) ([]*models.PostIdea, error) {
if status == "" {
status = "new"
}
return s.store.List(status)
}
// DeleteIdea removes an idea and any draft posts.
func (s *Service) DeleteIdea(ctx context.Context, id string) error {
idea, err := s.store.Get(id)
if err != nil {
return err
}
if idea.DraftPostID > 0 {
_ = s.wpClient.DeleteDraft(ctx, idea.DraftPostID)
}
if idea.DraftPostIDEs > 0 {
_ = s.wpClient.DeleteDraft(ctx, idea.DraftPostIDEs)
}
return s.store.Delete(id)
}
// GenerateDrafts creates draft posts in WordPress for the idea.
func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostIdea, error) {
idea, err := s.store.Get(id)
if err != nil {
return nil, err
}
styleSamples, err := s.fetchStyleSamples(ctx)
if err != nil {
s.logger.Warn("Failed to fetch style samples", "error", err)
}
sourceURLs := ideaSourceURLs(idea)
langs := s.languages
if len(langs) == 0 {
langs = []string{"en", "es"}
}
drafts := make(map[string]*models.ArticleDraft)
for _, lang := range langs {
if lang != "en" && lang != "es" {
s.logger.Warn("Skipping unsupported idea draft language", "language", lang)
continue
}
prompt, err := BuildArticlePrompt(ArticlePromptData{
Language: languageLabel(lang),
IdeaTitle: idea.SEOTitle,
IdeaSummary: idea.Summary,
PrimaryKW: idea.PrimaryKW,
Sources: sourceURLs,
StyleSamples: styleSamples,
SiteName: s.profile.Name,
SiteURL: s.profile.URL,
SiteTopics: strings.Join(s.profile.Topics, ", "),
SiteAudience: s.profile.Audience,
SiteVoice: s.profile.Voice,
ContentGuidelines: s.profile.ContentGuidelines,
})
if err != nil {
return nil, fmt.Errorf("failed to build %s article prompt: %w", strings.ToUpper(lang), err)
}
draft, err := s.llm.GenerateArticleDraft(ctx, prompt)
if err != nil {
return nil, err
}
draft.Language = lang
drafts[lang] = draft
}
if len(drafts) == 0 {
return nil, fmt.Errorf("no draft languages available")
}
enID := 0
esID := 0
if s.dryRun {
s.logger.Info("Dry-run enabled, skipping WordPress draft creation for idea")
} else {
if draft, ok := drafts["en"]; ok {
var err error
enID, err = s.wpClient.CreateDraftPost(ctx, wordpress.PostCreate{
Status: "draft",
Title: draft.Title,
Content: draft.ContentHTML,
Excerpt: chooseExcerpt(draft),
Lang: "en",
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: draft.SEOTitle,
wordpress.MetaRankMathDescription: draft.MetaDescription,
},
})
if err != nil {
return nil, fmt.Errorf("failed to create EN draft: %w", err)
}
}
if draft, ok := drafts["es"]; ok {
var err error
esID, err = s.wpClient.CreateDraftPost(ctx, wordpress.PostCreate{
Status: "draft",
Title: draft.Title,
Content: draft.ContentHTML,
Excerpt: chooseExcerpt(draft),
Lang: "es",
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: draft.SEOTitle,
wordpress.MetaRankMathDescription: draft.MetaDescription,
},
})
if err != nil {
return nil, fmt.Errorf("failed to create ES draft: %w", err)
}
}
}
idea.ArticleEN = drafts["en"]
idea.ArticleES = drafts["es"]
idea.DraftPostID = enID
idea.DraftPostIDEs = esID
idea.Status = "drafted"
idea.UpdatedAt = time.Now()
if err := s.store.Delete(idea.ID); err != nil {
s.logger.Debug("Failed to remove old idea record", "id", idea.ID, "error", err)
}
if err := s.store.Save(idea); err != nil {
return nil, err
}
return idea, nil
}
// PublishDrafts publishes the drafts created for the idea.
func (s *Service) PublishDrafts(ctx context.Context, id string) (*models.PostIdea, error) {
idea, err := s.store.Get(id)
if err != nil {
return nil, err
}
if s.dryRun {
return nil, fmt.Errorf("dry-run enabled: publishing is disabled")
}
if idea.DraftPostID <= 0 && idea.DraftPostIDEs <= 0 {
return nil, fmt.Errorf("idea has no draft posts")
}
if idea.DraftPostID > 0 {
if err := s.wpClient.UpdatePostStatus(ctx, idea.DraftPostID, "publish"); err != nil {
return nil, fmt.Errorf("failed to publish EN draft: %w", err)
}
}
if idea.DraftPostIDEs > 0 {
if err := s.wpClient.UpdatePostStatus(ctx, idea.DraftPostIDEs, "publish"); err != nil {
return nil, fmt.Errorf("failed to publish ES draft: %w", err)
}
}
idea.Status = "published"
now := time.Now()
idea.PublishedAt = &now
idea.UpdatedAt = time.Now()
if err := s.store.Delete(idea.ID); err != nil {
s.logger.Debug("Failed to remove old idea record", "id", idea.ID, "error", err)
}
if err := s.store.Save(idea); err != nil {
return nil, err
}
return idea, nil
}
func (s *Service) fetchStyleSamples(ctx context.Context) ([]StyleSample, error) {
posts, err := s.wpClient.ListPosts(ctx, wordpress.ListParams{
Status: "publish",
OrderBy: "date",
Order: "desc",
PerPage: s.cfg.StyleSamplePosts,
})
if err != nil {
return nil, err
}
samples := make([]StyleSample, 0, len(posts))
for _, post := range posts {
samples = append(samples, StyleSample{
Title: strings.TrimSpace(post.Title.Rendered),
Excerpt: truncate(stripHTML(post.Excerpt.Rendered), 300),
Content: truncate(stripHTML(post.Content.Rendered), 800),
})
}
return samples, nil
}
func ideaSourceURLs(idea *models.PostIdea) []string {
var urls []string
for _, src := range idea.Sources {
if src.URL != "" {
urls = append(urls, src.URL)
}
}
for _, ref := range idea.References {
if ref != "" {
urls = append(urls, ref)
}
}
return urls
}
func normalizeLanguages(langs []string) []string {
if len(langs) == 0 {
return []string{"en", "es"}
}
seen := make(map[string]struct{})
out := make([]string, 0, len(langs))
for _, lang := range langs {
trimmed := strings.ToLower(strings.TrimSpace(lang))
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
if len(out) == 0 {
return []string{"en", "es"}
}
return out
}
func canonicalRedditURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return strings.TrimRight(trimmed, "/")
}
host := strings.ToLower(u.Hostname())
if strings.HasPrefix(host, "old.") {
host = strings.TrimPrefix(host, "old.")
}
if strings.HasPrefix(host, "www.") {
host = strings.TrimPrefix(host, "www.")
}
u.Scheme = "https"
u.Host = host
u.RawQuery = ""
u.Fragment = ""
cleanPath := strings.TrimRight(u.EscapedPath(), "/")
if cleanPath == "" {
cleanPath = "/"
}
u.Path = cleanPath
return u.String()
}
func languageLabel(code string) string {
switch strings.ToLower(strings.TrimSpace(code)) {
case "es":
return "Spanish"
case "en":
return "English"
default:
return code
}
}
func chooseExcerpt(draft *models.ArticleDraft) string {
if draft.Excerpt != "" {
return draft.Excerpt
}
return draft.MetaDescription
}
func truncate(value string, max int) string {
if len(value) <= max {
return value
}
return value[:max] + "..."
}
func stripHTML(value string) string {
out := strings.Builder{}
inTag := false
for _, r := range value {
switch r {
case '<':
inTag = true
case '>':
inTag = false
default:
if !inTag {
out.WriteRune(r)
}
}
}
return strings.TrimSpace(out.String())
}