648 lines
16 KiB
Go
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())
|
|
}
|