Minor updates

This commit is contained in:
alexandrev-tibco
2026-02-20 16:21:18 +01:00
parent 9ca1c1dac3
commit 3a66d687e1
9 changed files with 518 additions and 45 deletions
+2
View File
@@ -142,6 +142,8 @@ Single-site setup: copy `configs/config.example.yaml` to `configs/config.yaml` a
Multi-site setup: copy `configs/multi.example.yaml` to `configs/config.yaml` and customize the `global` block plus each entry in `sites` (including `data_base_path` to keep storage isolated).
For outreach, set `outreach.min_score` and `outreach.min_comments` per site so thresholds match subreddit traffic (for example, `kubernetes` can use higher values than `paternidad`).
```yaml
wordpress:
base_url: "https://your-blog.com"
+6
View File
@@ -77,6 +77,9 @@ sites:
outreach:
subreddit: "kubernetes"
# Higher threshold for high-traffic communities
min_score: 40
min_comments: 10
newsletter:
enabled: true
@@ -131,6 +134,9 @@ sites:
outreach:
subreddit: "paternidad"
# Lower threshold for lower-traffic communities
min_score: 5
min_comments: 1
newsletter:
enabled: false
+47 -2
View File
@@ -182,6 +182,7 @@ func (s *Server) Start() error {
router.HandleFunc("/api/v1/drafts/{id}", s.handleGetDraft).Methods("GET")
router.HandleFunc("/api/v1/outreach", s.handleListOutreach).Methods("GET")
router.HandleFunc("/api/v1/outreach/generate", s.handleGenerateOutreach).Methods("POST")
router.HandleFunc("/api/v1/outreach/{id}/status", s.handleUpdateOutreachStatus).Methods("POST")
router.HandleFunc("/api/v1/outreach/{id}/delete", s.handleDeleteOutreach).Methods("POST")
router.HandleFunc("/api/v1/newsletter", s.handleListNewsletter).Methods("GET")
router.HandleFunc("/api/v1/newsletter/generate", s.handleGenerateNewsletter).Methods("POST")
@@ -855,8 +856,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": s.version,
"status": "ok",
"version": s.version,
"base_url": site.WPClient.BaseURL(),
})
}
@@ -1119,6 +1120,42 @@ func (s *Server) handleDeleteOutreach(w http.ResponseWriter, r *http.Request) {
})
}
// handleUpdateOutreachStatus handles POST /api/v1/outreach/{id}/status
func (s *Server) handleUpdateOutreachStatus(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
var req struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request payload", http.StatusBadRequest)
return
}
item, err := site.IdeaSvc.UpdateOutreachStatus(id, req.Status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to update outreach suggestion status: %v", err), http.StatusBadRequest)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "outreach_status_updated",
Summary: fmt.Sprintf("Updated outreach suggestion %s to %s", id, item.Status),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
// handleListNewsletter handles GET /api/v1/newsletter?status=draft
func (s *Server) handleListNewsletter(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
@@ -1762,6 +1799,14 @@ func (s *Server) handleListOrphans(w http.ResponseWriter, r *http.Request) {
http.Error(w, "translation service not configured", http.StatusNotImplemented)
return
}
if len(site.Config.Scheduler.Languages) < 2 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": 0,
"orphans": []interface{}{},
})
return
}
source := r.URL.Query().Get("source")
target := r.URL.Query().Get("target")
orphans, err := site.TranslationSvc.ListOrphanPosts(r.Context(), source, target)
+95 -15
View File
@@ -128,6 +128,7 @@ const state = {
imageAudit: null,
seoAudit: null,
orphans: [],
outreachStatus: localStorage.getItem("wpsk-outreach-status") || "new",
pagination: {},
seoRecommendations: {},
currentPage: "overview",
@@ -159,6 +160,11 @@ const toggleHidden = (id, hidden) => {
el.classList.toggle("hidden", hidden);
};
const siteSupportsOrphans = (site) => {
const langs = site?.languages || [];
return Array.isArray(langs) && langs.length > 1;
};
const applySiteFeatures = (site) => {
if (!site || !site.features) return;
toggleHidden("panel-newsletter", !site.features.newsletter);
@@ -167,6 +173,7 @@ const applySiteFeatures = (site) => {
toggleHidden("generate-outreach", !site.features.outreach);
toggleHidden("panel-ideas", !site.features.ideas);
toggleHidden("generate-ideas", !site.features.ideas);
toggleHidden("panel-orphans", !siteSupportsOrphans(site));
};
const setCurrentSite = async (siteId, { refresh = true } = {}) => {
@@ -919,6 +926,22 @@ const renderIdeas = () => {
});
};
const getOutreachFilter = () => {
const select = byId("outreach-status-filter");
if (select && select.value) return select.value;
return state.outreachStatus || "new";
};
const setOutreachFilter = (status) => {
const next = status || "new";
state.outreachStatus = next;
localStorage.setItem("wpsk-outreach-status", next);
const select = byId("outreach-status-filter");
if (select) {
select.value = next;
}
};
const renderOutreach = () => {
const list = byId("outreach-list");
renderPaginatedList({
@@ -931,12 +954,26 @@ const renderOutreach = () => {
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
const status = item.status || "new";
const actions = [
`<button class="secondary" data-outreach-view="${item.id}">View</button>`,
];
if (status !== "drafted") {
actions.push(`<button class="secondary" data-outreach-status="${item.id}" data-status-value="drafted">Mark Drafted</button>`);
}
if (status !== "reviewed") {
actions.push(`<button class="secondary" data-outreach-status="${item.id}" data-status-value="reviewed">Mark Reviewed</button>`);
}
if (status !== "new") {
actions.push(`<button class="secondary" data-outreach-status="${item.id}" data-status-value="new">Move to New</button>`);
}
actions.push(`<button class="danger" data-outreach-delete="${item.id}">Delete</button>`);
el.innerHTML = `
<h3>${item.reddit_title || "Reddit Thread"}</h3>
<div class="meta">${item.article_title || "Article"}</div>
<div class="meta"><span class="badge">${status}</span></div>
<div class="actions">
<button class="secondary" data-outreach-view="${item.id}">View</button>
<button class="danger" data-outreach-delete="${item.id}">Delete</button>
${actions.join("")}
</div>
`;
list.appendChild(el);
@@ -1376,12 +1413,15 @@ const loadIdeas = async () => {
const loadOutreach = async () => {
setLoading("panel-outreach", true);
try {
const res = await apiFetch("/api/v1/outreach?status=new");
const status = getOutreachFilter();
const res = await apiFetch(`/api/v1/outreach?status=${encodeURIComponent(status)}`);
state.outreach = res.items || [];
renderOutreach();
const newRes = await apiFetch("/api/v1/outreach?status=new");
const alert = byId("outreach-alert");
if (res.count > 0) {
alert.textContent = `${res.count} pending outreach suggestions`;
if (newRes.count > 0) {
alert.textContent = `${newRes.count} pending outreach suggestions`;
alert.classList.remove("hidden");
} else {
alert.classList.add("hidden");
@@ -1436,6 +1476,12 @@ const loadSEOAudit = async () => {
};
const loadOrphans = async () => {
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
if (!siteSupportsOrphans(site)) {
state.orphans = [];
renderOrphans();
return;
}
setLoading("panel-orphans", true);
try {
const res = await apiFetch("/api/v1/orphans?source=en&target=es");
@@ -1466,9 +1512,14 @@ const refreshAll = async () => {
loadBrokenLinks(),
loadImageAudit(),
loadSEOAudit(),
loadOrphans(),
loadAudit(),
];
if (siteSupportsOrphans(site)) {
tasks.push(loadOrphans());
} else {
state.orphans = [];
renderOrphans();
}
if (features.ideas !== false) {
tasks.push(loadIdeas());
}
@@ -1605,7 +1656,13 @@ const commandHandlers = {
};
return JSON.stringify(await apiFetch("/api/v1/search", { method: "POST", body: JSON.stringify(payload) }), null, 2);
},
orphans: async () => JSON.stringify(await apiFetch("/api/v1/orphans?source=en&target=es"), null, 2),
orphans: async () => {
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
if (!siteSupportsOrphans(site)) {
return "Orphan posts are only available for sites with multiple languages.";
}
return JSON.stringify(await apiFetch("/api/v1/orphans?source=en&target=es"), null, 2);
},
translate: async (args) => {
if (!args[0]) return "translate <post_id> [lang]";
return JSON.stringify(
@@ -1781,7 +1838,8 @@ const runCommand = async () => {
try {
const res = await handler(args);
setOutput(res);
await Promise.all([
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
const reloadTasks = [
loadPending(),
loadIdeas(),
loadOutreach(),
@@ -1789,8 +1847,16 @@ const runCommand = async () => {
loadBrokenLinks(),
loadImageAudit(),
loadSEOAudit(),
loadOrphans(),
loadAudit(),
];
if (siteSupportsOrphans(site)) {
reloadTasks.push(loadOrphans());
} else {
state.orphans = [];
renderOrphans();
}
await Promise.all([
...reloadTasks,
]);
} catch (err) {
appendOutput(`Error: ${err.message}`);
@@ -1888,6 +1954,11 @@ const wireActions = () => {
});
byId("refresh-pending").addEventListener("click", loadPending);
byId("refresh-ideas").addEventListener("click", loadIdeas);
setOutreachFilter(state.outreachStatus);
byId("outreach-status-filter").addEventListener("change", async (e) => {
setOutreachFilter(e.target.value);
await loadOutreach();
});
byId("refresh-outreach").addEventListener("click", loadOutreach);
byId("refresh-newsletter").addEventListener("click", loadNewsletters);
byId("refresh-broken-links").addEventListener("click", loadBrokenLinks);
@@ -2050,16 +2121,25 @@ const wireActions = () => {
byId("outreach-list").addEventListener("click", async (e) => {
const view = e.target.getAttribute("data-outreach-view");
const del = e.target.getAttribute("data-outreach-delete");
const statusID = e.target.getAttribute("data-outreach-status");
const statusValue = e.target.getAttribute("data-status-value");
if (view) {
const res = await apiFetch("/api/v1/outreach?status=new");
const item = (res.items || []).find((it) => it.id === view);
const item = (state.outreach || []).find((it) => it.id === view);
if (item) {
showModalText(
"Outreach Suggestion",
`${item.reddit_url}\n\n${item.article_title}\n${item.article_url}\n\n${item.suggested_response}`
);
showModalText(
"Outreach Suggestion",
`${item.reddit_url}\n\n${item.article_title}\n${item.article_url}\n\n${item.suggested_response}`
);
}
}
if (statusID && statusValue) {
await apiFetch(`/api/v1/outreach/${statusID}/status`, {
method: "POST",
body: JSON.stringify({ status: statusValue }),
});
await loadOutreach();
await loadAudit();
}
if (del) {
await apiFetch(`/api/v1/outreach/${del}/delete`, { method: "POST" });
await loadOutreach();
+10 -1
View File
@@ -204,7 +204,16 @@
<div class="panel" id="panel-outreach">
<div class="panel-header">
<h3>Outreach</h3>
<button id="refresh-outreach" class="secondary sm">Refresh</button>
<div class="panel-actions">
<select id="outreach-status-filter" class="sm">
<option value="new">New</option>
<option value="drafted">Drafted</option>
<option value="reviewed">Reviewed</option>
<option value="dismissed">Dismissed</option>
<option value="all">All</option>
</select>
<button id="refresh-outreach" class="secondary sm">Refresh</button>
</div>
</div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div></div></div>
+31
View File
@@ -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) {
+113 -12
View File
@@ -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":
+188 -14
View File
@@ -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))
}
}
+26 -1
View File
@@ -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)