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()) }