diff --git a/internal/config/multi_test.go b/internal/config/multi_test.go
index dd090c4..3c99719 100644
--- a/internal/config/multi_test.go
+++ b/internal/config/multi_test.go
@@ -49,6 +49,10 @@ sites:
app_password: "${WP_APP_PASSWORD_AV}"
scheduler:
languages: ["en", "es"]
+ outreach:
+ subreddit: "kubernetes"
+ min_score: 40
+ min_comments: 10
- id: "padre"
name: "Padre Primerizo"
data_base_path: "./data/sites/padre"
@@ -58,6 +62,10 @@ sites:
app_password: "${WP_APP_PASSWORD_PADRE}"
scheduler:
languages: ["es"]
+ outreach:
+ subreddit: "paternidad"
+ min_score: 5
+ min_comments: 1
newsletter:
enabled: false
`)
@@ -86,6 +94,12 @@ sites:
if padre.Config.Newsletter.Enabled {
t.Fatalf("expected newsletter disabled for padre")
}
+ if padre.Config.Outreach.MinScore != 5 {
+ t.Fatalf("expected padre outreach min_score 5, got %d", padre.Config.Outreach.MinScore)
+ }
+ if padre.Config.Outreach.MinComments != 1 {
+ t.Fatalf("expected padre outreach min_comments 1, got %d", padre.Config.Outreach.MinComments)
+ }
gotIdeas := filepath.Clean(padre.Config.Ideas.BasePath)
expectedIdeas := filepath.Clean("./data/sites/padre/ideas")
if gotIdeas != expectedIdeas {
@@ -96,6 +110,23 @@ sites:
if gotStorage != expectedStorage {
t.Fatalf("expected storage base path %q, got %q", expectedStorage, gotStorage)
}
+
+ var av *SiteDefinition
+ for i := range set.Sites {
+ if set.Sites[i].ID == "av" {
+ av = &set.Sites[i]
+ break
+ }
+ }
+ if av == nil {
+ t.Fatalf("av site not found")
+ }
+ if av.Config.Outreach.MinScore != 40 {
+ t.Fatalf("expected av outreach min_score 40, got %d", av.Config.Outreach.MinScore)
+ }
+ if av.Config.Outreach.MinComments != 10 {
+ t.Fatalf("expected av outreach min_comments 10, got %d", av.Config.Outreach.MinComments)
+ }
}
func TestLoadConfigSetLegacy(t *testing.T) {
diff --git a/internal/ideas/service.go b/internal/ideas/service.go
index db60f33..85b72f2 100644
--- a/internal/ideas/service.go
+++ b/internal/ideas/service.go
@@ -3,6 +3,7 @@ package ideas
import (
"context"
"fmt"
+ "net/url"
"strings"
"time"
@@ -165,6 +166,7 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou
}
filtered := make([]reddit.Post, 0, len(posts))
+ seenCandidates := make(map[string]struct{}, len(posts))
for _, post := range posts {
if post.Score < cfg.MinScore {
continue
@@ -172,12 +174,56 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou
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)
@@ -200,7 +246,7 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou
MaxSuggestions: cfg.MaxSuggestions,
Articles: articleRefs,
}
- for _, post := range filtered {
+ for _, post := range candidatePosts {
link := post.PermalinkURL
if link == "" {
link = post.URL
@@ -230,7 +276,20 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou
}
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()
@@ -257,6 +316,20 @@ func (s *Service) ListOutreach(status string) ([]*models.OutreachSuggestion, err
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 {
@@ -314,17 +387,17 @@ func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostId
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,
+ 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 {
@@ -502,6 +575,34 @@ func normalizeLanguages(langs []string) []string {
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":
diff --git a/internal/ideas/service_test.go b/internal/ideas/service_test.go
index a2b254a..2d00022 100644
--- a/internal/ideas/service_test.go
+++ b/internal/ideas/service_test.go
@@ -15,13 +15,19 @@ import (
"seo-optimizer/pkg/models"
)
-type fakeLLM struct{}
+type fakeLLM struct {
+ postIdeas []*models.PostIdea
+ outreachSuggestions []*models.OutreachSuggestion
+}
func (f *fakeLLM) OptimizePost(ctx context.Context, post *wordpress.Post, internalPosts []*wordpress.InternalPostReference, categories map[int]string, tags map[int]string, options *agent.OptimizeOptions) (*models.OptimizationResponse, error) {
return nil, nil
}
func (f *fakeLLM) GeneratePostIdeas(ctx context.Context, prompt string) ([]*models.PostIdea, error) {
+ if f.postIdeas != nil {
+ return f.postIdeas, nil
+ }
return []*models.PostIdea{
{
SEOTitle: "Idea SEO",
@@ -47,7 +53,10 @@ func (f *fakeLLM) GenerateArticleDraft(ctx context.Context, prompt string) (*mod
}
func (f *fakeLLM) GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error) {
- return nil, nil
+ if f.outreachSuggestions != nil {
+ return f.outreachSuggestions, nil
+ }
+ return []*models.OutreachSuggestion{}, nil
}
func (f *fakeLLM) GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error) {
@@ -61,17 +70,17 @@ func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) {
"data": map[string]interface{}{
"children": []map[string]interface{}{
{"data": map[string]interface{}{
- "title": "Post A",
- "url": "https://reddit.com/r/test/a",
- "permalink": "/r/test/comments/a",
- "selftext": "Body",
- "author": "user",
- "score": 50,
+ "title": "Post A",
+ "url": "https://reddit.com/r/test/a",
+ "permalink": "/r/test/comments/a",
+ "selftext": "Body",
+ "author": "user",
+ "score": 50,
"num_comments": 10,
- "created_utc": 1710000000,
- "subreddit": "paternidad",
- "is_self": true,
- "stickied": false,
+ "created_utc": 1710000000,
+ "subreddit": "paternidad",
+ "is_self": true,
+ "stickied": false,
}},
},
},
@@ -88,8 +97,8 @@ func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) {
}
posts := []map[string]interface{}{
{
- "id": 1,
- "title": map[string]interface{}{"rendered": "Sample"},
+ "id": 1,
+ "title": map[string]interface{}{"rendered": "Sample"},
"excerpt": map[string]interface{}{"rendered": "Excerpt"},
"content": map[string]interface{}{"rendered": "Content"},
},
@@ -155,3 +164,168 @@ func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) {
t.Fatalf("expected no English draft for es-only site")
}
}
+
+func TestGenerateOutreachSuggestionsDeduplicatesAndSkipsExistingLinks(t *testing.T) {
+ redditServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]interface{}{
+ "data": map[string]interface{}{
+ "children": []map[string]interface{}{
+ {"data": map[string]interface{}{
+ "title": "Thread A",
+ "url": "https://www.reddit.com/r/kubernetes/comments/abc/thread_a/",
+ "permalink": "/r/kubernetes/comments/abc/thread_a/",
+ "selftext": "Need help with cluster setup",
+ "author": "user-a",
+ "score": 100,
+ "num_comments": 20,
+ "created_utc": 1710000000,
+ "subreddit": "kubernetes",
+ "is_self": true,
+ "stickied": false,
+ }},
+ {"data": map[string]interface{}{
+ "title": "Thread B",
+ "url": "https://www.reddit.com/r/kubernetes/comments/def/thread_b/",
+ "permalink": "/r/kubernetes/comments/def/thread_b/",
+ "selftext": "How to debug networking",
+ "author": "user-b",
+ "score": 120,
+ "num_comments": 30,
+ "created_utc": 1710000500,
+ "subreddit": "kubernetes",
+ "is_self": true,
+ "stickied": false,
+ }},
+ },
+ },
+ }
+ _ = json.NewEncoder(w).Encode(resp)
+ }))
+ defer redditServer.Close()
+
+ wpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/wp-json/wp/v2/posts" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ posts := []map[string]interface{}{
+ {
+ "id": 10,
+ "slug": "kubernetes-guide",
+ "link": "https://example.com/kubernetes-guide",
+ "title": map[string]interface{}{"rendered": "Kubernetes Guide"},
+ "excerpt": map[string]interface{}{"rendered": "Guide excerpt"},
+ "status": "publish",
+ },
+ }
+ _ = json.NewEncoder(w).Encode(posts)
+ }))
+ defer wpServer.Close()
+
+ redditClient := reddit.NewClient(redditServer.URL, nil)
+ wpClient := wordpress.NewClient(wpServer.URL, "user", "pass", 0, nil)
+
+ ideasStore := storage.NewIdeaStorage(t.TempDir(), nil)
+ if err := ideasStore.Initialize(); err != nil {
+ t.Fatalf("init ideas storage: %v", err)
+ }
+ outreachStore := storage.NewOutreachStorage(t.TempDir(), nil)
+ if err := outreachStore.Initialize(); err != nil {
+ t.Fatalf("init outreach storage: %v", err)
+ }
+
+ // Existing suggestion for thread B should prevent regeneration for the same Reddit URL.
+ if err := outreachStore.Save(&models.OutreachSuggestion{
+ ID: "existing-b",
+ RedditURL: "https://www.reddit.com/r/kubernetes/comments/def/thread_b/",
+ RedditTitle: "Thread B",
+ ArticleTitle: "Existing Article",
+ ArticleURL: "https://example.com/existing",
+ SuggestedResponse: "Existing response",
+ Status: "new",
+ }); err != nil {
+ t.Fatalf("seed outreach suggestion: %v", err)
+ }
+
+ ideasCfg := config.IdeasConfig{
+ Enabled: true,
+ IntervalHours: 24,
+ Subreddit: "kubernetes",
+ Listing: "top",
+ TimeRange: "day",
+ MaxRedditPosts: 5,
+ MaxIdeas: 1,
+ StyleSamplePosts: 1,
+ MinScore: 1,
+ MinComments: 1,
+ BasePath: t.TempDir(),
+ }
+ outreachCfg := config.OutreachConfig{
+ Enabled: true,
+ Subreddit: "kubernetes",
+ Listing: "top",
+ TimeRange: "day",
+ MaxRedditPosts: 5,
+ MaxSuggestions: 5,
+ MinScore: 1,
+ MinComments: 1,
+ MaxArticles: 10,
+ BasePath: t.TempDir(),
+ }
+ profile := config.SiteProfile{
+ Name: "Test Site",
+ URL: "https://example.com",
+ Topics: []string{"Kubernetes"},
+ Audience: "Engineers",
+ Voice: "Technical",
+ }
+
+ llm := &fakeLLM{
+ outreachSuggestions: []*models.OutreachSuggestion{
+ {
+ RedditURL: "https://www.reddit.com/r/kubernetes/comments/abc/thread_a/",
+ RedditTitle: "Thread A",
+ ArticleTitle: "Kubernetes Guide",
+ ArticleURL: "https://example.com/kubernetes-guide",
+ SuggestedResponse: "Helpful answer A.",
+ },
+ {
+ // Duplicate of Thread A using a different URL shape should be deduplicated.
+ RedditURL: "https://old.reddit.com/r/kubernetes/comments/abc/thread_a/?utm_source=foo",
+ RedditTitle: "Thread A",
+ ArticleTitle: "Kubernetes Guide",
+ ArticleURL: "https://example.com/kubernetes-guide",
+ SuggestedResponse: "Helpful answer A duplicate.",
+ },
+ {
+ // Existing thread should be skipped.
+ RedditURL: "https://www.reddit.com/r/kubernetes/comments/def/thread_b/",
+ RedditTitle: "Thread B",
+ ArticleTitle: "Kubernetes Guide",
+ ArticleURL: "https://example.com/kubernetes-guide",
+ SuggestedResponse: "Helpful answer B.",
+ },
+ },
+ }
+
+ svc := NewService(wpClient, llm, redditClient, ideasStore, ideasCfg, outreachStore, outreachCfg, profile, []string{"en"}, true, nil)
+
+ generated, err := svc.GenerateOutreachSuggestions(context.Background())
+ if err != nil {
+ t.Fatalf("GenerateOutreachSuggestions failed: %v", err)
+ }
+ if len(generated) != 1 {
+ t.Fatalf("expected 1 generated outreach suggestion, got %d", len(generated))
+ }
+ if generated[0].RedditURL != "https://reddit.com/r/kubernetes/comments/abc/thread_a" {
+ t.Fatalf("unexpected canonical reddit url: %s", generated[0].RedditURL)
+ }
+
+ all, err := outreachStore.List("all")
+ if err != nil {
+ t.Fatalf("list outreach all: %v", err)
+ }
+ if len(all) != 2 {
+ t.Fatalf("expected 2 total outreach suggestions (seed + new), got %d", len(all))
+ }
+}
diff --git a/internal/storage/outreach_storage.go b/internal/storage/outreach_storage.go
index 4ce34cf..0447fb0 100644
--- a/internal/storage/outreach_storage.go
+++ b/internal/storage/outreach_storage.go
@@ -37,6 +37,7 @@ func (s *OutreachStorage) Initialize() error {
dirs := []string{
s.basePath,
filepath.Join(s.basePath, "new"),
+ filepath.Join(s.basePath, "drafted"),
filepath.Join(s.basePath, "reviewed"),
filepath.Join(s.basePath, "dismissed"),
}
@@ -85,6 +86,14 @@ func (s *OutreachStorage) Save(suggestion *models.OutreachSuggestion) error {
// List returns suggestions by status.
func (s *OutreachStorage) List(status string) ([]*models.OutreachSuggestion, error) {
+ status = strings.ToLower(strings.TrimSpace(status))
+ if status == "" {
+ status = "new"
+ }
+ if status == "all" {
+ return s.listAll()
+ }
+
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
@@ -117,6 +126,22 @@ func (s *OutreachStorage) List(status string) ([]*models.OutreachSuggestion, err
return suggestions, nil
}
+func (s *OutreachStorage) listAll() ([]*models.OutreachSuggestion, error) {
+ statuses := []string{"new", "drafted", "reviewed", "dismissed"}
+ suggestions := make([]*models.OutreachSuggestion, 0)
+ for _, status := range statuses {
+ items, err := s.List(status)
+ if err != nil {
+ return nil, err
+ }
+ suggestions = append(suggestions, items...)
+ }
+ sort.Slice(suggestions, func(i, j int) bool {
+ return suggestions[i].CreatedAt.After(suggestions[j].CreatedAt)
+ })
+ return suggestions, nil
+}
+
// Delete removes a suggestion by ID.
func (s *OutreachStorage) Delete(id string) error {
suggestion, err := s.Get(id)
@@ -136,7 +161,7 @@ func (s *OutreachStorage) Delete(id string) error {
// Get retrieves a suggestion by ID.
func (s *OutreachStorage) Get(id string) (*models.OutreachSuggestion, error) {
- statuses := []string{"new", "reviewed", "dismissed"}
+ statuses := []string{"new", "drafted", "reviewed", "dismissed"}
for _, status := range statuses {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)