Minor updates
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user