This commit is contained in:
alexandrev-tibco
2026-02-02 17:43:53 +01:00
parent 3826d9f509
commit 4f5ab09b66
27 changed files with 2371 additions and 631 deletions
+3 -1
View File
@@ -138,7 +138,9 @@ Production-ready Go application that automatically optimizes WordPress blog post
### Configuration File
Copy `configs/config.example.yaml` to `configs/config.yaml` and customize:
Single-site setup: copy `configs/config.example.yaml` to `configs/config.yaml` and customize.
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).
```yaml
wordpress:
+11 -3
View File
@@ -19,14 +19,16 @@ const version = "1.0.0"
type client struct {
baseURL string
http *http.Client
site string
}
func newClient(baseURL string) *client {
func newClient(baseURL, site string) *client {
return &client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{
Timeout: 30 * time.Second,
},
site: strings.TrimSpace(site),
}
}
@@ -48,6 +50,9 @@ func (c *client) do(method, path string, payload interface{}) ([]byte, int, erro
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-WPSK-Client", "cli")
if strings.TrimSpace(c.site) != "" {
req.Header.Set("X-WPSK-Site", c.site)
}
resp, err := c.http.Do(req)
if err != nil {
@@ -68,10 +73,12 @@ func main() {
if defaultAPI == "" {
defaultAPI = "http://localhost:8080"
}
defaultSite := os.Getenv("WPSK_SITE")
apiURL := flag.String("api", defaultAPI, "API base URL")
siteID := flag.String("site", defaultSite, "Site ID")
versionFlag := flag.Bool("version", false, "Print CLI version")
usage := func() {
fmt.Fprintf(os.Stderr, "Usage: %s [--api URL] <command> [args]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Usage: %s [--api URL] [--site SITE_ID] <command> [args]\n\n", os.Args[0])
fmt.Fprintln(os.Stderr, "Commands:")
fmt.Fprintln(os.Stderr, " health")
fmt.Fprintln(os.Stderr, " pending [summary|text <draft_post_id>]")
@@ -103,6 +110,7 @@ func main() {
fmt.Fprintln(os.Stderr, " wp-sk-cli changes 123")
fmt.Fprintln(os.Stderr, " WPSK_API_URL=http://server:8080 wp-sk-cli pending")
fmt.Fprintln(os.Stderr, " wp-sk-cli approve 456 --api http://server:8080")
fmt.Fprintln(os.Stderr, " wp-sk-cli --site padre pending")
}
flag.Usage = usage
flag.Parse()
@@ -125,7 +133,7 @@ func main() {
return
}
c := newClient(*apiURL)
c := newClient(*apiURL, *siteID)
if cmd != "version" && cmd != "help" {
warnPendingOutreach(c)
}
+232 -232
View File
@@ -35,268 +35,290 @@ func main() {
configPath = "configs/config.yaml"
}
cfg, err := config.LoadConfig(configPath)
configSet, err := config.LoadConfigSet(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
logLevel := strings.TrimSpace(configSet.Global.Logging.Level)
logFormat := strings.TrimSpace(configSet.Global.Logging.Format)
if (logLevel == "" || logFormat == "") && len(configSet.Sites) > 0 {
if logLevel == "" {
logLevel = configSet.Sites[0].Config.Logging.Level
}
if logFormat == "" {
logFormat = configSet.Sites[0].Config.Logging.Format
}
}
if logLevel == "" {
logLevel = "INFO"
}
if logFormat == "" {
logFormat = "json"
}
// Initialize logger
log := logger.NewLogger(cfg.Logging.Level, cfg.Logging.Format)
log := logger.NewLogger(logLevel, logFormat)
log.Info("SEO Optimizer starting",
"version", version,
"config", configPath,
"sites", len(configSet.Sites),
)
// Initialize WordPress client
wpClient := wordpress.NewClient(
cfg.WordPress.BaseURL,
cfg.WordPress.Username,
cfg.WordPress.AppPassword,
cfg.WordPress.Timeout,
log,
)
log.Info("WordPress client initialized",
"base_url", cfg.WordPress.BaseURL,
"username", cfg.WordPress.Username,
)
llmProvider := strings.TrimSpace(configSet.Global.LLM.Provider)
if llmProvider == "" && len(configSet.Sites) > 0 {
llmProvider = strings.TrimSpace(configSet.Sites[0].Config.LLM.Provider)
}
// Initialize LLM client
var llmClient agent.LLMClient
switch cfg.LLM.Provider {
switch llmProvider {
case "claude":
claudeCfg := configSet.Global.Claude
if claudeCfg.APIKey == "" && len(configSet.Sites) > 0 {
claudeCfg = configSet.Sites[0].Config.Claude
}
llmClient = agent.NewClaudeClient(
cfg.Claude.APIKey,
cfg.Claude.Model,
cfg.Claude.MaxTokens,
cfg.Claude.Temperature,
claudeCfg.APIKey,
claudeCfg.Model,
claudeCfg.MaxTokens,
claudeCfg.Temperature,
log,
)
log.Info("Claude client initialized",
"model", cfg.Claude.Model,
"max_tokens", cfg.Claude.MaxTokens,
"model", claudeCfg.Model,
"max_tokens", claudeCfg.MaxTokens,
)
case "deepseek":
deepSeekCfg := configSet.Global.DeepSeek
if deepSeekCfg.APIKey == "" && len(configSet.Sites) > 0 {
deepSeekCfg = configSet.Sites[0].Config.DeepSeek
}
llmClient = agent.NewDeepSeekClient(
cfg.DeepSeek.APIKey,
cfg.DeepSeek.BaseURL,
cfg.DeepSeek.Model,
cfg.DeepSeek.MaxTokens,
cfg.DeepSeek.Temperature,
deepSeekCfg.APIKey,
deepSeekCfg.BaseURL,
deepSeekCfg.Model,
deepSeekCfg.MaxTokens,
deepSeekCfg.Temperature,
log,
)
log.Info("DeepSeek client initialized",
"model", cfg.DeepSeek.Model,
"max_tokens", cfg.DeepSeek.MaxTokens,
"base_url", cfg.DeepSeek.BaseURL,
"model", deepSeekCfg.Model,
"max_tokens", deepSeekCfg.MaxTokens,
"base_url", deepSeekCfg.BaseURL,
)
default:
log.Error("Unsupported LLM provider", "provider", cfg.LLM.Provider)
log.Error("Unsupported LLM provider", "provider", llmProvider)
os.Exit(1)
}
// Initialize DeepSeek client for translations (optional)
var translationClient *agent.DeepSeekClient
if strings.TrimSpace(cfg.DeepSeek.APIKey) != "" {
translationCfg := configSet.Global.DeepSeek
if translationCfg.APIKey == "" && len(configSet.Sites) > 0 {
translationCfg = configSet.Sites[0].Config.DeepSeek
}
if strings.TrimSpace(translationCfg.APIKey) != "" {
translationClient = agent.NewDeepSeekClient(
cfg.DeepSeek.APIKey,
cfg.DeepSeek.BaseURL,
cfg.DeepSeek.Model,
cfg.DeepSeek.MaxTokens,
cfg.DeepSeek.Temperature,
translationCfg.APIKey,
translationCfg.BaseURL,
translationCfg.Model,
translationCfg.MaxTokens,
translationCfg.Temperature,
log,
)
log.Info("DeepSeek translation client initialized", "model", cfg.DeepSeek.Model)
log.Info("DeepSeek translation client initialized", "model", translationCfg.Model)
} else {
log.Warn("DeepSeek API key missing; translation feature will be unavailable")
}
// Initialize storage
store := storage.NewOptimizationStorage(cfg.Storage.BasePath, log)
if err := store.Initialize(); err != nil {
log.Error("Failed to initialize storage", "error", err)
os.Exit(1)
}
log.Info("Storage initialized", "base_path", cfg.Storage.BasePath)
// Initialize audit storage
auditStore := storage.NewAuditStorage(cfg.Audit.BasePath, log)
if err := auditStore.Initialize(); err != nil {
log.Error("Failed to initialize audit storage", "error", err)
os.Exit(1)
}
log.Info("Audit storage initialized", "base_path", cfg.Audit.BasePath)
// Initialize idea storage
ideaStore := storage.NewIdeaStorage(cfg.Ideas.BasePath, log)
if err := ideaStore.Initialize(); err != nil {
log.Error("Failed to initialize idea storage", "error", err)
os.Exit(1)
}
log.Info("Idea storage initialized", "base_path", cfg.Ideas.BasePath)
// Initialize outreach storage
outreachStore := storage.NewOutreachStorage(cfg.Outreach.BasePath, log)
if err := outreachStore.Initialize(); err != nil {
log.Error("Failed to initialize outreach storage", "error", err)
os.Exit(1)
}
log.Info("Outreach storage initialized", "base_path", cfg.Outreach.BasePath)
// Initialize newsletter storage
newsStore := storage.NewNewsletterStorage(cfg.Newsletter.BasePath, log)
if err := newsStore.Initialize(); err != nil {
log.Error("Failed to initialize newsletter storage", "error", err)
os.Exit(1)
}
log.Info("Newsletter storage initialized", "base_path", cfg.Newsletter.BasePath)
// Initialize broken link storage
linkStore := storage.NewBrokenLinkStorage(cfg.LinkChecker.BasePath, log)
if err := linkStore.Initialize(); err != nil {
log.Error("Failed to initialize broken link storage", "error", err)
os.Exit(1)
}
log.Info("Broken link storage initialized", "base_path", cfg.LinkChecker.BasePath)
// Initialize image audit storage
imageAuditStore := storage.NewImageAuditStorage(cfg.ImageAudit.BasePath, log)
if err := imageAuditStore.Initialize(); err != nil {
log.Error("Failed to initialize image audit storage", "error", err)
os.Exit(1)
}
log.Info("Image audit storage initialized", "base_path", cfg.ImageAudit.BasePath)
// Initialize SEO audit storage
seoAuditStore := storage.NewSEOAuditStorage(cfg.SEOAudit.BasePath, log)
if err := seoAuditStore.Initialize(); err != nil {
log.Error("Failed to initialize SEO audit storage", "error", err)
os.Exit(1)
}
log.Info("SEO audit storage initialized", "base_path", cfg.SEOAudit.BasePath)
// Initialize idea service
ideaService := ideas.NewService(wpClient, llmClient, nil, ideaStore, cfg.Ideas, outreachStore, cfg.Outreach, cfg.Runtime.DryRun, log)
// Initialize newsletter service
newsService := newsletter.NewService(llmClient, nil, newsStore, cfg.Newsletter, log)
// Initialize broken link service
linkService, err := brokenlinks.NewService(wpClient, linkStore, cfg.LinkChecker, log)
if err != nil {
log.Error("Failed to initialize broken link service", "error", err)
os.Exit(1)
}
imageAuditService, err := imageaudit.NewService(wpClient, imageAuditStore, cfg.ImageAudit, log)
if err != nil {
log.Error("Failed to initialize image audit service", "error", err)
os.Exit(1)
}
seoAuditService, err := seoaudit.NewService(wpClient, seoAuditStore, cfg.SEOAudit, log)
if err != nil {
log.Error("Failed to initialize SEO audit service", "error", err)
os.Exit(1)
}
translationService := translation.NewService(wpClient, translationClient, log)
// Initialize optimizer
optimizer := seo.NewOptimizer(wpClient, llmClient, cfg.LLM.Provider, store, cfg, log)
log.Info("Optimizer initialized")
// Context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
siteContexts := make([]*api.SiteContext, 0, len(configSet.Sites))
stopFns := make([]func(), 0)
for _, siteDef := range configSet.Sites {
cfg := siteDef.Config
siteLog := log.WithFields("site", siteDef.ID)
// Initialize WordPress client
wpClient := wordpress.NewClient(
cfg.WordPress.BaseURL,
cfg.WordPress.Username,
cfg.WordPress.AppPassword,
cfg.WordPress.Timeout,
siteLog,
)
siteLog.Info("WordPress client initialized",
"base_url", cfg.WordPress.BaseURL,
"username", cfg.WordPress.Username,
)
store := storage.NewOptimizationStorage(cfg.Storage.BasePath, siteLog)
if err := store.Initialize(); err != nil {
log.Error("Failed to initialize storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
auditStore := storage.NewAuditStorage(cfg.Audit.BasePath, siteLog)
if err := auditStore.Initialize(); err != nil {
log.Error("Failed to initialize audit storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
ideaStore := storage.NewIdeaStorage(cfg.Ideas.BasePath, siteLog)
if err := ideaStore.Initialize(); err != nil {
log.Error("Failed to initialize idea storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
outreachStore := storage.NewOutreachStorage(cfg.Outreach.BasePath, siteLog)
if err := outreachStore.Initialize(); err != nil {
log.Error("Failed to initialize outreach storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
newsStore := storage.NewNewsletterStorage(cfg.Newsletter.BasePath, siteLog)
if err := newsStore.Initialize(); err != nil {
log.Error("Failed to initialize newsletter storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
linkStore := storage.NewBrokenLinkStorage(cfg.LinkChecker.BasePath, siteLog)
if err := linkStore.Initialize(); err != nil {
log.Error("Failed to initialize broken link storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
imageAuditStore := storage.NewImageAuditStorage(cfg.ImageAudit.BasePath, siteLog)
if err := imageAuditStore.Initialize(); err != nil {
log.Error("Failed to initialize image audit storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
seoAuditStore := storage.NewSEOAuditStorage(cfg.SEOAudit.BasePath, siteLog)
if err := seoAuditStore.Initialize(); err != nil {
log.Error("Failed to initialize SEO audit storage", "error", err, "site", siteDef.ID)
os.Exit(1)
}
ideaService := ideas.NewService(wpClient, llmClient, nil, ideaStore, cfg.Ideas, outreachStore, cfg.Outreach, cfg.Profile, cfg.Scheduler.Languages, cfg.Runtime.DryRun, siteLog)
newsService := newsletter.NewService(llmClient, nil, newsStore, cfg.Newsletter, siteLog)
linkService, err := brokenlinks.NewService(wpClient, linkStore, cfg.LinkChecker, siteLog)
if err != nil {
log.Error("Failed to initialize broken link service", "error", err, "site", siteDef.ID)
os.Exit(1)
}
imageAuditService, err := imageaudit.NewService(wpClient, imageAuditStore, cfg.ImageAudit, siteLog)
if err != nil {
log.Error("Failed to initialize image audit service", "error", err, "site", siteDef.ID)
os.Exit(1)
}
seoAuditService, err := seoaudit.NewService(wpClient, seoAuditStore, cfg.SEOAudit, siteLog)
if err != nil {
log.Error("Failed to initialize SEO audit service", "error", err, "site", siteDef.ID)
os.Exit(1)
}
translationService := translation.NewService(wpClient, translationClient, siteLog)
optimizer := seo.NewOptimizer(wpClient, llmClient, cfg.LLM.Provider, store, cfg, siteLog)
siteLog.Info("Optimizer initialized")
siteContexts = append(siteContexts, &api.SiteContext{
ID: siteDef.ID,
Name: siteDef.Name,
Config: cfg,
Optimizer: optimizer,
WPClient: wpClient,
Storage: store,
Audit: auditStore,
IdeaSvc: ideaService,
NewsSvc: newsService,
LinkSvc: linkService,
ImageSvc: imageAuditService,
SEOAuditSvc: seoAuditService,
TranslationSvc: translationService,
})
if cfg.Scheduler.Enabled {
sched := scheduler.NewScheduler(
optimizer,
cfg.Scheduler.IntervalHours,
cfg.Scheduler.Languages,
siteLog,
)
go func(local *scheduler.Scheduler, id string) {
if err := local.Start(ctx); err != nil && err != context.Canceled {
log.Error("Scheduler failed", "error", err, "site", id)
}
}(sched, siteDef.ID)
siteLog.Info("Scheduler started",
"interval", fmt.Sprintf("%.1fh", cfg.Scheduler.IntervalHours),
"languages", cfg.Scheduler.Languages,
)
stopFns = append(stopFns, sched.Stop)
}
if cfg.Ideas.Enabled || cfg.Outreach.Enabled {
ideaSched := scheduler.NewIdeaScheduler(ideaService, cfg.Ideas.IntervalHours, cfg.Ideas.Enabled, cfg.Outreach.Enabled, auditStore, siteLog)
go func(local *scheduler.IdeaScheduler, id string) {
if err := local.Start(ctx); err != nil && err != context.Canceled {
log.Error("Idea scheduler failed", "error", err, "site", id)
}
}(ideaSched, siteDef.ID)
siteLog.Info("Idea scheduler started", "interval", cfg.Ideas.IntervalHours)
stopFns = append(stopFns, ideaSched.Stop)
}
if cfg.Newsletter.Enabled {
newsSched := scheduler.NewNewsletterScheduler(newsService, cfg.Newsletter.IntervalHours, auditStore, siteLog)
go func(local *scheduler.NewsletterScheduler, id string) {
if err := local.Start(ctx); err != nil && err != context.Canceled {
log.Error("Newsletter scheduler failed", "error", err, "site", id)
}
}(newsSched, siteDef.ID)
siteLog.Info("Newsletter scheduler started", "interval", cfg.Newsletter.IntervalHours)
stopFns = append(stopFns, newsSched.Stop)
}
if cfg.LinkChecker.Enabled {
linkSched := scheduler.NewBrokenLinkScheduler(linkService, cfg.LinkChecker.IntervalHours, auditStore, siteLog)
go func(local *scheduler.BrokenLinkScheduler, id string) {
if err := local.Start(ctx); err != nil && err != context.Canceled {
log.Error("Broken link scheduler failed", "error", err, "site", id)
}
}(linkSched, siteDef.ID)
siteLog.Info("Broken link scheduler started", "interval", cfg.LinkChecker.IntervalHours)
stopFns = append(stopFns, linkSched.Stop)
}
if cfg.ImageAudit.Enabled {
imageAuditSched := scheduler.NewImageAuditScheduler(imageAuditService, cfg.ImageAudit.IntervalHours, auditStore, siteLog)
go func(local *scheduler.ImageAuditScheduler, id string) {
if err := local.Start(ctx); err != nil && err != context.Canceled {
log.Error("Image audit scheduler failed", "error", err, "site", id)
}
}(imageAuditSched, siteDef.ID)
siteLog.Info("Image audit scheduler started", "interval", cfg.ImageAudit.IntervalHours)
stopFns = append(stopFns, imageAuditSched.Stop)
}
}
// Start API server if enabled
var apiServer *api.Server
if cfg.API.Enabled {
apiServer = api.NewServer(optimizer, wpClient, store, auditStore, ideaService, newsService, linkService, imageAuditService, seoAuditService, translationService, version, cfg.API.Port, log)
if configSet.Global.API.Enabled {
apiServer = api.NewServer(siteContexts, configSet.DefaultSite, version, configSet.Global.API.Port, log)
// Start server in goroutine
go func() {
if err := apiServer.Start(); err != nil && err != http.ErrServerClosed {
log.Error("API server failed", "error", err)
}
}()
// Start periodic job cleanup
go apiServer.StartPeriodicCleanup(ctx, 1*time.Hour, 24*time.Hour)
log.Info("API server started", "port", cfg.API.Port)
}
// Start scheduler if enabled
var sched *scheduler.Scheduler
if cfg.Scheduler.Enabled {
sched = scheduler.NewScheduler(
optimizer,
cfg.Scheduler.IntervalHours,
cfg.Scheduler.Languages,
log,
)
go func() {
if err := sched.Start(ctx); err != nil && err != context.Canceled {
log.Error("Scheduler failed", "error", err)
}
}()
log.Info("Scheduler started",
"interval", fmt.Sprintf("%.1fh", cfg.Scheduler.IntervalHours),
"languages", cfg.Scheduler.Languages,
)
}
// Start idea scheduler if enabled
var ideaSched *scheduler.IdeaScheduler
if cfg.Ideas.Enabled || cfg.Outreach.Enabled {
ideaSched = scheduler.NewIdeaScheduler(ideaService, cfg.Ideas.IntervalHours, cfg.Ideas.Enabled, cfg.Outreach.Enabled, auditStore, log)
go func() {
if err := ideaSched.Start(ctx); err != nil && err != context.Canceled {
log.Error("Idea scheduler failed", "error", err)
}
}()
log.Info("Idea scheduler started", "interval", cfg.Ideas.IntervalHours)
}
// Start newsletter scheduler if enabled
var newsSched *scheduler.NewsletterScheduler
if cfg.Newsletter.Enabled {
newsSched = scheduler.NewNewsletterScheduler(newsService, cfg.Newsletter.IntervalHours, auditStore, log)
go func() {
if err := newsSched.Start(ctx); err != nil && err != context.Canceled {
log.Error("Newsletter scheduler failed", "error", err)
}
}()
log.Info("Newsletter scheduler started", "interval", cfg.Newsletter.IntervalHours)
}
// Start broken link scheduler if enabled
var linkSched *scheduler.BrokenLinkScheduler
if cfg.LinkChecker.Enabled {
linkSched = scheduler.NewBrokenLinkScheduler(linkService, cfg.LinkChecker.IntervalHours, auditStore, log)
go func() {
if err := linkSched.Start(ctx); err != nil && err != context.Canceled {
log.Error("Broken link scheduler failed", "error", err)
}
}()
log.Info("Broken link scheduler started", "interval", cfg.LinkChecker.IntervalHours)
}
// Start image audit scheduler if enabled
var imageAuditSched *scheduler.ImageAuditScheduler
if cfg.ImageAudit.Enabled {
imageAuditSched = scheduler.NewImageAuditScheduler(imageAuditService, cfg.ImageAudit.IntervalHours, auditStore, log)
go func() {
if err := imageAuditSched.Start(ctx); err != nil && err != context.Canceled {
log.Error("Image audit scheduler failed", "error", err)
}
}()
log.Info("Image audit scheduler started", "interval", cfg.ImageAudit.IntervalHours)
log.Info("API server started", "port", configSet.Global.API.Port)
}
// Wait for interrupt signal
@@ -324,30 +346,8 @@ func main() {
}
}
// Stop scheduler
if sched != nil {
sched.Stop()
log.Info("Scheduler stopped")
}
if ideaSched != nil {
ideaSched.Stop()
log.Info("Idea scheduler stopped")
}
if newsSched != nil {
newsSched.Stop()
log.Info("Newsletter scheduler stopped")
}
if linkSched != nil {
linkSched.Stop()
log.Info("Broken link scheduler stopped")
}
if imageAuditSched != nil {
imageAuditSched.Stop()
log.Info("Image audit scheduler stopped")
for _, stop := range stopFns {
stop()
}
log.Info("SEO Optimizer stopped gracefully")
+40
View File
@@ -15,6 +15,46 @@ wordpress:
# API request timeout
timeout: 30s
profile:
# Human-friendly site name used in prompts
name: "Alexandre Vazquez"
# Canonical site URL
url: "https://alexandre-vazquez.com"
# Primary topics covered by the site
topics:
- "Kubernetes"
- "DevOps"
- "Platform engineering"
- "Enterprise infrastructure"
# Intended audience
audience: "Senior engineers, architects, SREs"
# Writing voice/tone
voice: "Practical, technical, and concise"
# High-level optimization goals (used in SEO prompt)
goals:
- "Rank higher in traditional search engines (Google, Bing)"
- "Be a trusted reference for LLM/AI agents (Claude, ChatGPT, etc.)"
- "Increase internal link coverage"
- "Maintain technical accuracy and depth"
# Preferred fact-checking sources (domains or URLs)
fact_check_sources:
- "kubernetes.io"
- "docs.tibco.com"
- "pve.proxmox.com"
- "docs.ceph.com"
# Additional content guidelines for article drafts
content_guidelines:
- "Target length: 1,200-2,000 words."
- "Include 1 short code block if relevant."
- "Keep technical terms and product names in English."
runtime:
# When true, do not create or publish WordPress drafts
dry_run: false
+8
View File
@@ -8,6 +8,14 @@ The SEO Optimizer provides a REST API for manual optimization triggers and manag
http://localhost:8080/api/v1
```
## Multi-Site Header
When running multiple sites, include the `X-WPSK-Site` header with the site ID to scope requests:
```
X-WPSK-Site: padre
```
## Endpoints
### 1. Health Check
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"time"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
@@ -104,10 +105,12 @@ func (c *ClaudeClient) OptimizePost(
// Build prompt data
feedback := ""
var profile config.SiteProfile
if options != nil {
feedback = options.Feedback
profile = options.Profile
}
promptData := BuildPromptData(post, internalPosts, categories, tags, feedback)
promptData := BuildPromptData(post, internalPosts, categories, tags, feedback, profile)
// Render prompt
prompt, err := RenderPrompt(promptData)
+4 -1
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
@@ -92,10 +93,12 @@ func (c *DeepSeekClient) OptimizePost(
)
feedback := ""
var profile config.SiteProfile
if options != nil {
feedback = options.Feedback
profile = options.Profile
}
promptData := BuildPromptData(post, internalPosts, categories, tags, feedback)
promptData := BuildPromptData(post, internalPosts, categories, tags, feedback, profile)
prompt, err := RenderPrompt(promptData)
if err != nil {
return nil, fmt.Errorf("failed to render prompt: %w", err)
+2
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"seo-optimizer/internal/config"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
@@ -27,4 +28,5 @@ type LLMClient interface {
// OptimizeOptions provides optional inputs to guide optimization.
type OptimizeOptions struct {
Feedback string
Profile config.SiteProfile
}
+30 -17
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"seo-optimizer/internal/config"
"seo-optimizer/internal/wordpress"
)
@@ -25,6 +26,13 @@ type PromptData struct {
Content string
InternalPosts []InternalPostRef
Feedback string
SiteName string
SiteURL string
SiteTopics string
SiteAudience string
SiteVoice string
SiteGoals []string
FactCheckSources []string
}
// InternalPostRef is a simplified reference to an internal post for linking
@@ -36,19 +44,18 @@ type InternalPostRef struct {
}
// SEOOptimizationPromptTemplate is the comprehensive prompt sent to the LLM
const SEOOptimizationPromptTemplate = `You are an expert SEO optimizer for technical blogs focused on Kubernetes, DevOps, platform engineering, and enterprise integration.
const SEOOptimizationPromptTemplate = `You are an expert SEO optimizer for {{.SiteName}}.
**Task**: Analyze the provided blog post and generate comprehensive SEO optimizations.
**Blog Context**:
- Site: alexandre-vazquez.com
- Topics: Kubernetes, TIBCO BusinessWorks, Proxmox, Ceph, automation, DevOps, platform engineering
- Target Audience: Senior engineers, architects, SREs
- Optimization Goals:
1. Rank higher in traditional search engines (Google, Bing)
2. Be a trusted reference for LLM/AI agents (Claude, ChatGPT, etc.)
3. Increase internal link coverage
4. Maintain technical accuracy and depth
- Site: {{.SiteName}} ({{.SiteURL}})
- Topics: {{.SiteTopics}}
- Target Audience: {{.SiteAudience}}
- Voice/Tone: {{.SiteVoice}}
{{if .SiteGoals}}- Optimization Goals:
{{range .SiteGoals}} - {{.}}
{{end}}{{end}}
**Language**: {{.Language}}
@@ -181,12 +188,10 @@ Respond with ONLY valid JSON (no markdown code blocks, no explanations outside J
- Questions should cover: What, Why, How, When, Best practices
5. **Validation & Fact-Checking**:
- Verify technical claims against official documentation:
* Kubernetes: kubernetes.io
* TIBCO: docs.tibco.com
* Proxmox: pve.proxmox.com
* Ceph: docs.ceph.com
- Check for outdated version numbers, deprecated APIs, old practices
- Verify factual claims against authoritative sources
{{if .FactCheckSources}} - Preferred sources:
{{range .FactCheckSources}} * {{.}}
{{end}}{{end}}
- Validate external links (simulate check: 404s, moved pages, redirects)
- If uncertain about accuracy, mark as "uncertain" and flag for review
- Prioritize official sources over blog posts or Stack Overflow
@@ -203,11 +208,11 @@ Respond with ONLY valid JSON (no markdown code blocks, no explanations outside J
- NEVER remove substantial content without strong justification
- ALWAYS provide "reason" fields for transparency
- ALWAYS write summary in {{if eq .Language "es"}}Spanish{{else}}English{{end}} for human reviewer
- Keep technical depth and accuracy (this is an expert audience)
- Preserve domain accuracy and depth appropriate for the audience
- Maintain original author's voice and style
- After generating optimized content, review it for Gutenberg block compatibility (tables, headings, images, code blocks). Fix structure issues (headings without proper tags, extra phrases inside headings, malformed tables/images). Keep code blocks coherent and intact.
- If you add a table, use valid Gutenberg structure: wrap in <!-- wp:table --> and <figure class="wp-block-table"><table>...</table></figure>.
- Never translate code or technical terminology (objects, products, techniques). Examples: Kubernetes objects, Helm, TLS edge-termination.
- Keep product names, brands, and proper nouns in their original language.
- Return ONLY valid JSON, no markdown formatting, no text outside JSON
`
@@ -283,6 +288,7 @@ func BuildPromptData(
categories map[int]string,
tags map[int]string,
feedback string,
profile config.SiteProfile,
) PromptData {
// Get SEO meta fields (try RankMath first, then Yoast)
seoTitle := getMetaString(post.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
@@ -347,6 +353,13 @@ func BuildPromptData(
Content: post.Content.Rendered,
InternalPosts: internalRefs,
Feedback: strings.TrimSpace(feedback),
SiteName: strings.TrimSpace(profile.Name),
SiteURL: strings.TrimSpace(profile.URL),
SiteTopics: strings.Join(profile.Topics, ", "),
SiteAudience: strings.TrimSpace(profile.Audience),
SiteVoice: strings.TrimSpace(profile.Voice),
SiteGoals: profile.Goals,
FactCheckSources: profile.FactCheckSources,
}
}
+80
View File
@@ -0,0 +1,80 @@
package agent
import (
"strings"
"testing"
"time"
"seo-optimizer/internal/config"
"seo-optimizer/internal/wordpress"
)
func TestBuildPromptDataIncludesProfile(t *testing.T) {
post := &wordpress.Post{
ID: 123,
Slug: "test-post",
Title: wordpress.Rendered{Rendered: "Test Title"},
Content: wordpress.Rendered{Rendered: "Hello world"},
Excerpt: wordpress.Rendered{Rendered: "Excerpt"},
Modified: wordpress.WPTime{Time: time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)},
Lang: "es",
}
profile := config.SiteProfile{
Name: "Padre Primerizo",
URL: "https://padreprimerizo.com",
Topics: []string{"Paternidad", "Crianza"},
Audience: "Padres primerizos",
Voice: "Cercano",
Goals: []string{"Mejorar SEO"},
FactCheckSources: []string{"aeped.es", "who.int"},
}
data := BuildPromptData(post, nil, nil, nil, "", profile)
if data.SiteName != "Padre Primerizo" {
t.Fatalf("expected SiteName to be set")
}
if data.SiteURL != "https://padreprimerizo.com" {
t.Fatalf("expected SiteURL to be set")
}
if data.SiteTopics != "Paternidad, Crianza" {
t.Fatalf("expected topics to be joined, got %q", data.SiteTopics)
}
if data.SiteAudience == "" || data.SiteVoice == "" {
t.Fatalf("expected audience and voice to be set")
}
}
func TestRenderPromptInjectsSiteContext(t *testing.T) {
post := &wordpress.Post{
ID: 1,
Slug: "slug",
Title: wordpress.Rendered{Rendered: "Title"},
Content: wordpress.Rendered{Rendered: "Content"},
Modified: wordpress.WPTime{Time: time.Now()},
Lang: "en",
}
profile := config.SiteProfile{
Name: "My Site",
URL: "https://example.com",
Topics: []string{"Topic A"},
Audience: "Engineers",
Voice: "Direct",
Goals: []string{"Rank higher"},
FactCheckSources: []string{"example.com"},
}
data := BuildPromptData(post, nil, nil, nil, "", profile)
prompt, err := RenderPrompt(data)
if err != nil {
t.Fatalf("RenderPrompt failed: %v", err)
}
if !strings.Contains(prompt, "My Site") {
t.Fatalf("expected prompt to include site name")
}
if !strings.Contains(prompt, "https://example.com") {
t.Fatalf("expected prompt to include site url")
}
if !strings.Contains(prompt, "Topic A") {
t.Fatalf("expected prompt to include topics")
}
}
+429 -191
View File
File diff suppressed because it is too large Load Diff
+115 -64
View File
@@ -31,8 +31,13 @@ const toggleTheme = () => {
initTheme();
const apiFetch = async (path, options = {}) => {
const headers = { "Content-Type": "application/json", "X-WPSK-Client": "webui" };
const siteHeader = options.site !== undefined ? options.site : state.currentSite;
if (siteHeader) {
headers["X-WPSK-Site"] = siteHeader;
}
const res = await fetch(path, {
headers: { "Content-Type": "application/json", "X-WPSK-Client": "webui" },
headers,
...options,
});
const text = await res.text();
@@ -110,6 +115,10 @@ const getPaginationState = (key, size = 6) => {
};
const state = {
sites: [],
siteMap: {},
currentSite: null,
defaultSite: null,
pending: [],
ideas: [],
outreach: [],
@@ -133,6 +142,85 @@ const state = {
ideaJobs: {}, // ideaId -> { jobId, status, drafts }
};
const setSiteLabel = (site) => {
const label = byId("site-label");
if (!label) return;
if (!site) {
label.textContent = "-";
return;
}
const name = site.name || site.id || "Site";
label.textContent = name;
};
const toggleHidden = (id, hidden) => {
const el = byId(id);
if (!el) return;
el.classList.toggle("hidden", hidden);
};
const applySiteFeatures = (site) => {
if (!site || !site.features) return;
toggleHidden("panel-newsletter", !site.features.newsletter);
toggleHidden("generate-newsletter", !site.features.newsletter);
toggleHidden("panel-outreach", !site.features.outreach);
toggleHidden("generate-outreach", !site.features.outreach);
toggleHidden("panel-ideas", !site.features.ideas);
toggleHidden("generate-ideas", !site.features.ideas);
};
const setCurrentSite = async (siteId, { refresh = true } = {}) => {
if (!siteId || !state.siteMap[siteId]) return;
state.currentSite = siteId;
localStorage.setItem("wpsk-site", siteId);
const site = state.siteMap[siteId];
setSiteLabel(site);
applySiteFeatures(site);
const select = byId("site-select");
if (select && select.value !== siteId) {
select.value = siteId;
}
if (refresh) {
await refreshAll();
}
};
const renderSiteSwitcher = () => {
const select = byId("site-select");
if (!select) return;
select.innerHTML = "";
for (const site of state.sites) {
const opt = document.createElement("option");
opt.value = site.id;
opt.textContent = site.name || site.id;
select.appendChild(opt);
}
if (state.currentSite) {
select.value = state.currentSite;
}
select.addEventListener("change", async (e) => {
await setCurrentSite(e.target.value);
});
};
const initSites = async () => {
try {
const data = await apiFetch("/api/v1/sites", { site: null });
state.sites = data.sites || [];
state.defaultSite = data.default_site || (state.sites[0] && state.sites[0].id);
state.siteMap = {};
for (const site of state.sites) {
state.siteMap[site.id] = site;
}
const stored = localStorage.getItem("wpsk-site");
const initial = stored && state.siteMap[stored] ? stored : state.defaultSite;
await setCurrentSite(initial, { refresh: false });
renderSiteSwitcher();
} catch (err) {
appendOutput(`Error loading sites: ${err.message}`);
}
};
const appendOutput = (text) => {
const output = byId("command-output");
output.textContent = `${output.textContent}${text}\n`;
@@ -150,59 +238,10 @@ const stripHTML = (html) => {
return div.textContent || div.innerText || "";
};
const diffTokens = (beforeText, afterText) => {
const a = beforeText.split(/\s+/).filter(Boolean);
const b = afterText.split(/\s+/).filter(Boolean);
const m = a.length;
const n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 1; i <= m; i += 1) {
for (let j = 1; j <= n; j += 1) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
let i = m;
let j = n;
const ops = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
ops.push({ type: "equal", value: a[i - 1] });
i -= 1;
j -= 1;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: "add", value: b[j - 1] });
j -= 1;
} else if (i > 0) {
ops.push({ type: "del", value: a[i - 1] });
i -= 1;
}
}
return ops.reverse();
};
const renderDiffHTML = (beforeText, afterText, side) => {
const ops = diffTokens(beforeText, afterText);
const parts = [];
for (const op of ops) {
if (op.type === "equal") {
parts.push(op.value);
continue;
}
if (side === "original" && op.type === "del") {
parts.push(`<span class="diff-del">${op.value}</span>`);
continue;
}
if (side === "draft" && op.type === "add") {
parts.push(`<span class="diff-add">${op.value}</span>`);
continue;
}
}
return parts.join(" ");
};
const { getIdeaDraftIds, diffTokens, renderDiffHTML } = window.WPSKUIUtils || {};
if (!getIdeaDraftIds || !diffTokens || !renderDiffHTML) {
throw new Error("WPSKUIUtils is required");
}
// Build newsletter in Substack-compatible markdown format
const buildNewsletterMarkdown = (item, ad, includeAd = true) => {
@@ -557,17 +596,18 @@ const showIdeaDraftModal = async (idea) => {
try {
// Fetch draft content for EN and ES if available
const drafts = {};
if (idea.draft_en_post_id) {
const ids = getIdeaDraftIds(idea);
if (ids.en) {
try {
const enDraft = await apiFetch(`/api/v1/drafts/${idea.draft_en_post_id}`);
const enDraft = await apiFetch(`/api/v1/drafts/${ids.en}`);
drafts.en = enDraft;
} catch (e) {
console.warn("Could not load EN draft", e);
}
}
if (idea.draft_es_post_id) {
if (ids.es) {
try {
const esDraft = await apiFetch(`/api/v1/drafts/${idea.draft_es_post_id}`);
const esDraft = await apiFetch(`/api/v1/drafts/${ids.es}`);
drafts.es = esDraft;
} catch (e) {
console.warn("Could not load ES draft", e);
@@ -838,7 +878,8 @@ const renderIdeas = () => {
const isInProgress = state.inProgress.ideaDrafts.has(item.id);
const isDrafted = item.status === "drafted";
const hasDrafts = item.draft_en_post_id || item.draft_es_post_id;
const { en: draftEn, es: draftEs } = getIdeaDraftIds(item);
const hasDrafts = draftEn || draftEs;
// Build status badge
let statusBadge = `<span class="badge">${item.status}</span>`;
@@ -1417,18 +1458,27 @@ const loadAudit = async () => {
};
const refreshAll = async () => {
await Promise.all([
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
const features = site?.features || {};
const tasks = [
loadHealth(),
loadPending(),
loadIdeas(),
loadOutreach(),
loadNewsletters(),
loadBrokenLinks(),
loadImageAudit(),
loadSEOAudit(),
loadOrphans(),
loadAudit(),
]);
];
if (features.ideas !== false) {
tasks.push(loadIdeas());
}
if (features.outreach !== false) {
tasks.push(loadOutreach());
}
if (features.newsletter) {
tasks.push(loadNewsletters());
}
await Promise.all(tasks);
};
const commandHandlers = {
@@ -2134,5 +2184,6 @@ window.addEventListener("DOMContentLoaded", async () => {
wireActions();
initPage();
renderCommandSuggestions();
await initSites();
await refreshAll();
});
+6 -1
View File
@@ -14,10 +14,14 @@
<img class="logo" src="logo.svg" alt="WPSideKick" />
<div>
<h1>WPSideKick</h1>
<div class="sub">v<span id="server-version">-</span></div>
<div class="sub"><span id="site-label">-</span> · v<span id="server-version">-</span></div>
</div>
</div>
<div class="header-actions">
<div class="site-switch">
<label for="site-select">Site</label>
<select id="site-select"></select>
</div>
<button id="refresh-all" class="secondary">Refresh All</button>
<button id="theme-toggle" class="theme-toggle" title="Toggle theme">
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -353,6 +357,7 @@
</div>
</div>
<script src="ui-utils.js"></script>
<script src="app.js"></script>
</body>
</html>
+25
View File
@@ -157,6 +157,31 @@ a:hover {
gap: 8px;
}
.site-switch {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
}
.site-switch label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.site-switch select {
border: none;
background: transparent;
color: var(--color-text);
font-size: 13px;
outline: none;
}
/* Theme Toggle */
.theme-toggle {
display: flex;
+75
View File
@@ -0,0 +1,75 @@
(function (root, factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory();
return;
}
root.WPSKUIUtils = factory();
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
const getIdeaDraftIds = (idea) => ({
en: (idea && (idea.draft_post_id || idea.draft_en_post_id)) || null,
es: (idea && (idea.draft_post_id_es || idea.draft_es_post_id)) || null,
});
const diffTokens = (beforeText, afterText) => {
const a = String(beforeText || "").split(/\s+/).filter(Boolean);
const b = String(afterText || "").split(/\s+/).filter(Boolean);
const m = a.length;
const n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 1; i <= m; i += 1) {
for (let j = 1; j <= n; j += 1) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
let i = m;
let j = n;
const ops = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
ops.push({ type: "equal", value: a[i - 1] });
i -= 1;
j -= 1;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: "add", value: b[j - 1] });
j -= 1;
} else if (i > 0) {
ops.push({ type: "del", value: a[i - 1] });
i -= 1;
}
}
return ops.reverse();
};
const renderDiffHTML = (beforeText, afterText, side) => {
const ops = diffTokens(beforeText, afterText);
const parts = [];
for (const op of ops) {
if (op.type === "equal") {
parts.push(op.value);
continue;
}
if (side === "original" && op.type === "del") {
parts.push('<span class="diff-del">' + op.value + "</span>");
continue;
}
if (side === "draft" && op.type === "add") {
parts.push('<span class="diff-add">' + op.value + "</span>");
continue;
}
}
return parts.join(" ");
};
return {
getIdeaDraftIds,
diffTokens,
renderDiffHTML,
};
});
+31
View File
@@ -0,0 +1,31 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { getIdeaDraftIds, diffTokens, renderDiffHTML } = require('./ui-utils.js');
test('getIdeaDraftIds supports legacy and current fields', () => {
assert.deepEqual(getIdeaDraftIds({ draft_en_post_id: 11, draft_es_post_id: 22 }), { en: 11, es: 22 });
assert.deepEqual(getIdeaDraftIds({ draft_post_id: 33, draft_post_id_es: 44 }), { en: 33, es: 44 });
assert.deepEqual(getIdeaDraftIds({}), { en: null, es: null });
});
test('diffTokens marks additions and deletions', () => {
const ops = diffTokens('a b c', 'a x c');
assert.equal(ops.length, 4);
assert.equal(ops[1].type, 'del');
assert.equal(ops[1].value, 'b');
assert.equal(ops[2].type, 'add');
assert.equal(ops[2].value, 'x');
});
test('renderDiffHTML renders side-specific markup', () => {
const before = 'alpha beta gamma';
const after = 'alpha delta gamma';
const original = renderDiffHTML(before, after, 'original');
const draft = renderDiffHTML(before, after, 'draft');
assert.match(original, /diff-del/);
assert.doesNotMatch(original, /diff-add/);
assert.match(draft, /diff-add/);
assert.doesNotMatch(draft, /diff-del/);
});
+158 -46
View File
@@ -16,6 +16,7 @@ type Config struct {
Claude ClaudeConfig `mapstructure:"claude"`
DeepSeek DeepSeekConfig `mapstructure:"deepseek"`
Runtime RuntimeConfig `mapstructure:"runtime"`
Profile SiteProfile `mapstructure:"profile"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
Optimization OptimizationConfig `mapstructure:"optimization"`
Ideas IdeasConfig `mapstructure:"ideas"`
@@ -39,6 +40,18 @@ type WordPressConfig struct {
Timeout time.Duration `mapstructure:"timeout"`
}
// SiteProfile holds site-specific prompt context.
type SiteProfile struct {
Name string `mapstructure:"name"`
URL string `mapstructure:"url"`
Topics []string `mapstructure:"topics"`
Audience string `mapstructure:"audience"`
Voice string `mapstructure:"voice"`
Goals []string `mapstructure:"goals"`
FactCheckSources []string `mapstructure:"fact_check_sources"`
ContentGuidelines []string `mapstructure:"content_guidelines"`
}
// RuntimeConfig holds runtime toggles.
type RuntimeConfig struct {
DryRun bool `mapstructure:"dry_run"`
@@ -289,58 +302,16 @@ func LoadConfig(configPath string) (*Config, error) {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Normalize LLM provider before validation
config.LLM.Provider = strings.ToLower(strings.TrimSpace(config.LLM.Provider))
if config.LLM.Provider == "" {
config.LLM.Provider = "claude"
}
config.Ideas.Listing = strings.ToLower(strings.TrimSpace(config.Ideas.Listing))
config.Ideas.TimeRange = strings.ToLower(strings.TrimSpace(config.Ideas.TimeRange))
if config.Ideas.Listing == "" {
config.Ideas.Listing = "top"
}
config.Outreach.Listing = strings.ToLower(strings.TrimSpace(config.Outreach.Listing))
config.Outreach.TimeRange = strings.ToLower(strings.TrimSpace(config.Outreach.TimeRange))
if config.Outreach.Listing == "" {
config.Outreach.Listing = "top"
}
config.Newsletter.Listing = strings.ToLower(strings.TrimSpace(config.Newsletter.Listing))
config.Newsletter.TimeRange = strings.ToLower(strings.TrimSpace(config.Newsletter.TimeRange))
if config.Newsletter.Listing == "" {
config.Newsletter.Listing = "top"
}
normalizeConfig(&config)
applyProfileDefaults(&config)
// Validate config
if err := validateConfig(&config); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
// Expand environment variables in config values
config.WordPress.AppPassword = os.ExpandEnv(config.WordPress.AppPassword)
config.Claude.APIKey = os.ExpandEnv(config.Claude.APIKey)
config.DeepSeek.APIKey = os.ExpandEnv(config.DeepSeek.APIKey)
config.DeepSeek.BaseURL = os.ExpandEnv(config.DeepSeek.BaseURL)
config.Ideas.BasePath = os.ExpandEnv(config.Ideas.BasePath)
config.Outreach.BasePath = os.ExpandEnv(config.Outreach.BasePath)
config.Newsletter.BasePath = os.ExpandEnv(config.Newsletter.BasePath)
config.ImageAudit.BasePath = os.ExpandEnv(config.ImageAudit.BasePath)
config.ImageAudit.BaseURL = os.ExpandEnv(config.ImageAudit.BaseURL)
config.SEOAudit.BasePath = os.ExpandEnv(config.SEOAudit.BasePath)
config.SEOAudit.BaseURL = os.ExpandEnv(config.SEOAudit.BaseURL)
config.LinkChecker.BasePath = os.ExpandEnv(config.LinkChecker.BasePath)
config.LinkChecker.BaseURL = os.ExpandEnv(config.LinkChecker.BaseURL)
config.Audit.BasePath = os.ExpandEnv(config.Audit.BasePath)
config.Notifications.WebhookURL = os.ExpandEnv(config.Notifications.WebhookURL)
if strings.TrimSpace(config.ImageAudit.BaseURL) == "" {
config.ImageAudit.BaseURL = config.WordPress.BaseURL
}
if strings.TrimSpace(config.SEOAudit.BaseURL) == "" {
config.SEOAudit.BaseURL = config.WordPress.BaseURL
}
if strings.TrimSpace(config.LinkChecker.BaseURL) == "" {
config.LinkChecker.BaseURL = config.WordPress.BaseURL
}
expandConfigEnv(&config)
applyDerivedDefaults(&config)
return &config, nil
}
@@ -452,6 +423,147 @@ func setDefaults() {
viper.SetDefault("logging.format", "json")
}
func setSiteDefaultsLegacy(v *viper.Viper) {
v.SetDefault("wordpress.timeout", 30*time.Second)
v.SetDefault("runtime.dry_run", false)
v.SetDefault("scheduler.enabled", true)
v.SetDefault("scheduler.interval_hours", 2.5)
v.SetDefault("scheduler.languages", []string{"en", "es"})
v.SetDefault("optimization.min_content_length", 500)
v.SetDefault("optimization.max_age_days", 365)
v.SetDefault("optimization.min_age_days", 30)
v.SetDefault("optimization.reoptimization_cooldown_days", 180)
v.SetDefault("optimization.max_internal_links", 8)
v.SetDefault("optimization.faq_min_questions", 4)
v.SetDefault("optimization.faq_max_questions", 6)
v.SetDefault("optimization.sync_translations", true)
v.SetDefault("ideas.enabled", true)
v.SetDefault("ideas.interval_hours", 24.0)
v.SetDefault("ideas.subreddit", "kubernetes")
v.SetDefault("ideas.listing", "top")
v.SetDefault("ideas.time_range", "day")
v.SetDefault("ideas.max_reddit_posts", 20)
v.SetDefault("ideas.max_ideas", 5)
v.SetDefault("ideas.style_sample_posts", 3)
v.SetDefault("ideas.min_score", 25)
v.SetDefault("ideas.min_comments", 5)
v.SetDefault("ideas.base_path", "./data/ideas")
v.SetDefault("outreach.enabled", true)
v.SetDefault("outreach.subreddit", "kubernetes")
v.SetDefault("outreach.listing", "top")
v.SetDefault("outreach.time_range", "day")
v.SetDefault("outreach.max_reddit_posts", 20)
v.SetDefault("outreach.max_suggestions", 5)
v.SetDefault("outreach.min_score", 25)
v.SetDefault("outreach.min_comments", 5)
v.SetDefault("outreach.max_articles", 50)
v.SetDefault("outreach.base_path", "./data/outreach")
v.SetDefault("newsletter.enabled", true)
v.SetDefault("newsletter.interval_hours", 168.0)
v.SetDefault("newsletter.feed_url", "https://alexandre-vazquez.com/feed/")
v.SetDefault("newsletter.subreddit", "kubernetes")
v.SetDefault("newsletter.listing", "top")
v.SetDefault("newsletter.time_range", "week")
v.SetDefault("newsletter.max_feed_items", 5)
v.SetDefault("newsletter.max_reddit_posts", 10)
v.SetDefault("newsletter.min_score", 10)
v.SetDefault("newsletter.min_comments", 2)
v.SetDefault("newsletter.base_path", "./data/newsletters")
v.SetDefault("image_audit.enabled", true)
v.SetDefault("image_audit.interval_hours", 168.0)
v.SetDefault("image_audit.base_url", "")
v.SetDefault("image_audit.base_path", "./data/image-audits")
v.SetDefault("image_audit.max_posts", 0)
v.SetDefault("image_audit.max_images_per_post", 0)
v.SetDefault("image_audit.allowed_host_suffixes", []string{"optimole.com"})
v.SetDefault("image_audit.download_timeout", 20*time.Second)
v.SetDefault("image_audit.max_download_bytes", int64(20*1024*1024))
v.SetDefault("seo_audit.enabled", true)
v.SetDefault("seo_audit.base_url", "")
v.SetDefault("seo_audit.base_path", "./data/seo-audits")
v.SetDefault("seo_audit.max_posts", 0)
v.SetDefault("link_checker.enabled", true)
v.SetDefault("link_checker.interval_hours", 168.0)
v.SetDefault("link_checker.request_timeout", 10*time.Second)
v.SetDefault("link_checker.max_posts", 0)
v.SetDefault("link_checker.max_links_per_post", 0)
v.SetDefault("link_checker.concurrency", 6)
v.SetDefault("link_checker.base_path", "./data/broken-links")
v.SetDefault("link_checker.base_url", "")
v.SetDefault("storage.base_path", "./data/optimizations")
v.SetDefault("audit.base_path", "./data/audit")
}
func normalizeConfig(config *Config) {
config.LLM.Provider = strings.ToLower(strings.TrimSpace(config.LLM.Provider))
if config.LLM.Provider == "" {
config.LLM.Provider = "claude"
}
config.Ideas.Listing = strings.ToLower(strings.TrimSpace(config.Ideas.Listing))
config.Ideas.TimeRange = strings.ToLower(strings.TrimSpace(config.Ideas.TimeRange))
if config.Ideas.Listing == "" {
config.Ideas.Listing = "top"
}
config.Outreach.Listing = strings.ToLower(strings.TrimSpace(config.Outreach.Listing))
config.Outreach.TimeRange = strings.ToLower(strings.TrimSpace(config.Outreach.TimeRange))
if config.Outreach.Listing == "" {
config.Outreach.Listing = "top"
}
config.Newsletter.Listing = strings.ToLower(strings.TrimSpace(config.Newsletter.Listing))
config.Newsletter.TimeRange = strings.ToLower(strings.TrimSpace(config.Newsletter.TimeRange))
if config.Newsletter.Listing == "" {
config.Newsletter.Listing = "top"
}
}
func expandConfigEnv(config *Config) {
config.WordPress.AppPassword = os.ExpandEnv(config.WordPress.AppPassword)
config.Claude.APIKey = os.ExpandEnv(config.Claude.APIKey)
config.DeepSeek.APIKey = os.ExpandEnv(config.DeepSeek.APIKey)
config.DeepSeek.BaseURL = os.ExpandEnv(config.DeepSeek.BaseURL)
config.Ideas.BasePath = os.ExpandEnv(config.Ideas.BasePath)
config.Outreach.BasePath = os.ExpandEnv(config.Outreach.BasePath)
config.Newsletter.BasePath = os.ExpandEnv(config.Newsletter.BasePath)
config.ImageAudit.BasePath = os.ExpandEnv(config.ImageAudit.BasePath)
config.ImageAudit.BaseURL = os.ExpandEnv(config.ImageAudit.BaseURL)
config.SEOAudit.BasePath = os.ExpandEnv(config.SEOAudit.BasePath)
config.SEOAudit.BaseURL = os.ExpandEnv(config.SEOAudit.BaseURL)
config.LinkChecker.BasePath = os.ExpandEnv(config.LinkChecker.BasePath)
config.LinkChecker.BaseURL = os.ExpandEnv(config.LinkChecker.BaseURL)
config.Audit.BasePath = os.ExpandEnv(config.Audit.BasePath)
config.Notifications.WebhookURL = os.ExpandEnv(config.Notifications.WebhookURL)
}
func applyDerivedDefaults(config *Config) {
if strings.TrimSpace(config.ImageAudit.BaseURL) == "" {
config.ImageAudit.BaseURL = config.WordPress.BaseURL
}
if strings.TrimSpace(config.SEOAudit.BaseURL) == "" {
config.SEOAudit.BaseURL = config.WordPress.BaseURL
}
if strings.TrimSpace(config.LinkChecker.BaseURL) == "" {
config.LinkChecker.BaseURL = config.WordPress.BaseURL
}
}
func applyProfileDefaults(config *Config) {
if strings.TrimSpace(config.Profile.Name) == "" {
config.Profile.Name = deriveSiteName(config.WordPress.BaseURL)
}
if strings.TrimSpace(config.Profile.URL) == "" {
config.Profile.URL = strings.TrimRight(config.WordPress.BaseURL, "/")
}
if len(config.Profile.Topics) == 0 {
config.Profile.Topics = []string{"your blog topics"}
}
if strings.TrimSpace(config.Profile.Audience) == "" {
config.Profile.Audience = "your target audience"
}
if strings.TrimSpace(config.Profile.Voice) == "" {
config.Profile.Voice = "the site's established voice"
}
}
// validateConfig validates the configuration
func validateConfig(cfg *Config) error {
// Validate WordPress config
+412
View File
@@ -0,0 +1,412 @@
package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/viper"
)
// GlobalConfig holds shared configuration across sites.
type GlobalConfig struct {
LLM LLMConfig `mapstructure:"llm"`
Claude ClaudeConfig `mapstructure:"claude"`
DeepSeek DeepSeekConfig `mapstructure:"deepseek"`
API APIConfig `mapstructure:"api"`
Logging LoggingConfig `mapstructure:"logging"`
}
// SiteConfig holds per-site configuration (excluding shared globals).
type SiteConfig struct {
ID string `mapstructure:"id"`
Name string `mapstructure:"name"`
DataBasePath string `mapstructure:"data_base_path"`
Profile SiteProfile `mapstructure:"profile"`
WordPress WordPressConfig `mapstructure:"wordpress"`
Runtime RuntimeConfig `mapstructure:"runtime"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
Optimization OptimizationConfig `mapstructure:"optimization"`
Ideas IdeasConfig `mapstructure:"ideas"`
Outreach OutreachConfig `mapstructure:"outreach"`
Newsletter NewsletterConfig `mapstructure:"newsletter"`
ImageAudit ImageAuditConfig `mapstructure:"image_audit"`
SEOAudit SEOAuditConfig `mapstructure:"seo_audit"`
LinkChecker LinkCheckerConfig `mapstructure:"link_checker"`
Storage StorageConfig `mapstructure:"storage"`
Audit AuditConfig `mapstructure:"audit"`
Notifications NotificationsConfig `mapstructure:"notifications"`
}
// SiteDefinition holds a resolved site configuration.
type SiteDefinition struct {
ID string
Name string
Config *Config
}
// ConfigSet is a multi-site configuration bundle.
type ConfigSet struct {
Global GlobalConfig
Sites []SiteDefinition
DefaultSite string
}
// LoadConfigSet loads configuration in either legacy single-site mode or multi-site mode.
func LoadConfigSet(configPath string) (*ConfigSet, error) {
if configPath == "" {
configPath = "configs/config.yaml"
}
v := viper.New()
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
cfg, loadErr := LoadConfig(configPath)
if loadErr != nil {
return nil, fmt.Errorf("config file not found: %s", configPath)
}
name := cfg.Profile.Name
if name == "" {
name = deriveSiteName(cfg.WordPress.BaseURL)
}
site := SiteDefinition{
ID: "default",
Name: name,
Config: cfg,
}
return &ConfigSet{
Global: GlobalConfig{
LLM: cfg.LLM,
Claude: cfg.Claude,
DeepSeek: cfg.DeepSeek,
API: cfg.API,
Logging: cfg.Logging,
},
Sites: []SiteDefinition{site},
DefaultSite: "default",
}, nil
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}
if v.IsSet("sites") || v.IsSet("global") {
return loadMultiConfig(v)
}
cfg, err := LoadConfig(configPath)
if err != nil {
return nil, err
}
name := cfg.Profile.Name
if name == "" {
name = deriveSiteName(cfg.WordPress.BaseURL)
}
site := SiteDefinition{
ID: "default",
Name: name,
Config: cfg,
}
return &ConfigSet{
Global: GlobalConfig{
LLM: cfg.LLM,
Claude: cfg.Claude,
DeepSeek: cfg.DeepSeek,
API: cfg.API,
Logging: cfg.Logging,
},
Sites: []SiteDefinition{site},
DefaultSite: "default",
}, nil
}
func loadMultiConfig(v *viper.Viper) (*ConfigSet, error) {
global, err := decodeGlobalConfig(v)
if err != nil {
return nil, err
}
sitesRaw := v.Get("sites")
sitesSlice, ok := sitesRaw.([]interface{})
if !ok || len(sitesSlice) == 0 {
return nil, fmt.Errorf("sites must be a non-empty list")
}
seen := make(map[string]struct{})
siteDefs := make([]SiteDefinition, 0, len(sitesSlice))
for idx, item := range sitesSlice {
siteMap, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("sites[%d] must be an object", idx)
}
site, err := decodeSiteConfig(siteMap)
if err != nil {
return nil, fmt.Errorf("sites[%d]: %w", idx, err)
}
if site.ID == "" {
return nil, fmt.Errorf("sites[%d].id is required", idx)
}
if _, exists := seen[site.ID]; exists {
return nil, fmt.Errorf("duplicate site id: %s", site.ID)
}
seen[site.ID] = struct{}{}
resolved := resolveSiteConfig(global, site)
if err := validateConfig(resolved); err != nil {
return nil, fmt.Errorf("site %s: %w", site.ID, err)
}
name := site.Name
if strings.TrimSpace(name) == "" {
name = resolved.Profile.Name
}
if strings.TrimSpace(name) == "" {
name = deriveSiteName(resolved.WordPress.BaseURL)
}
siteDefs = append(siteDefs, SiteDefinition{
ID: site.ID,
Name: name,
Config: resolved,
})
}
defaultSite := strings.TrimSpace(v.GetString("default_site"))
if defaultSite == "" && len(siteDefs) > 0 {
defaultSite = siteDefs[0].ID
}
if defaultSite != "" {
if _, ok := seen[defaultSite]; !ok {
return nil, fmt.Errorf("default_site %q not found in sites", defaultSite)
}
}
sort.Slice(siteDefs, func(i, j int) bool {
return siteDefs[i].ID < siteDefs[j].ID
})
return &ConfigSet{
Global: global,
Sites: siteDefs,
DefaultSite: defaultSite,
}, nil
}
func decodeGlobalConfig(v *viper.Viper) (GlobalConfig, error) {
settings := v.GetStringMap("global")
if len(settings) == 0 {
return GlobalConfig{}, fmt.Errorf("global section is required when using multi-site config")
}
gv := viper.New()
setGlobalDefaults(gv)
flattenSettings("", settings, gv)
var global GlobalConfig
if err := gv.Unmarshal(&global); err != nil {
return GlobalConfig{}, fmt.Errorf("failed to unmarshal global config: %w", err)
}
normalizeGlobal(&global)
expandGlobalEnv(&global)
return global, nil
}
func decodeSiteConfig(settings map[string]interface{}) (SiteConfig, error) {
sv := viper.New()
setSiteDefaultsMulti(sv)
flattenSettings("", settings, sv)
var site SiteConfig
if err := sv.Unmarshal(&site); err != nil {
return SiteConfig{}, fmt.Errorf("failed to unmarshal site config: %w", err)
}
applySiteDefaultsMulti(&site)
return site, nil
}
func resolveSiteConfig(global GlobalConfig, site SiteConfig) *Config {
cfg := &Config{
WordPress: site.WordPress,
LLM: global.LLM,
Claude: global.Claude,
DeepSeek: global.DeepSeek,
Runtime: site.Runtime,
Profile: site.Profile,
Scheduler: site.Scheduler,
Optimization: site.Optimization,
Ideas: site.Ideas,
Outreach: site.Outreach,
Newsletter: site.Newsletter,
ImageAudit: site.ImageAudit,
SEOAudit: site.SEOAudit,
LinkChecker: site.LinkChecker,
Storage: site.Storage,
Audit: site.Audit,
API: global.API,
Logging: global.Logging,
Notifications: site.Notifications,
}
applySiteBasePaths(cfg, site.ID, site.DataBasePath)
normalizeConfig(cfg)
expandConfigEnv(cfg)
applyDerivedDefaults(cfg)
applyProfileDefaults(cfg)
return cfg
}
func applySiteBasePaths(cfg *Config, siteID, basePath string) {
root := strings.TrimSpace(basePath)
if root == "" {
root = filepath.Join(".", "data", "sites", siteID)
}
if strings.TrimSpace(cfg.Ideas.BasePath) == "" {
cfg.Ideas.BasePath = filepath.Join(root, "ideas")
}
if strings.TrimSpace(cfg.Outreach.BasePath) == "" {
cfg.Outreach.BasePath = filepath.Join(root, "outreach")
}
if strings.TrimSpace(cfg.Newsletter.BasePath) == "" {
cfg.Newsletter.BasePath = filepath.Join(root, "newsletters")
}
if strings.TrimSpace(cfg.ImageAudit.BasePath) == "" {
cfg.ImageAudit.BasePath = filepath.Join(root, "image-audits")
}
if strings.TrimSpace(cfg.SEOAudit.BasePath) == "" {
cfg.SEOAudit.BasePath = filepath.Join(root, "seo-audits")
}
if strings.TrimSpace(cfg.LinkChecker.BasePath) == "" {
cfg.LinkChecker.BasePath = filepath.Join(root, "broken-links")
}
if strings.TrimSpace(cfg.Storage.BasePath) == "" {
cfg.Storage.BasePath = filepath.Join(root, "optimizations")
}
if strings.TrimSpace(cfg.Audit.BasePath) == "" {
cfg.Audit.BasePath = filepath.Join(root, "audit")
}
}
func setGlobalDefaults(v *viper.Viper) {
v.SetDefault("llm.provider", "claude")
v.SetDefault("claude.model", "claude-sonnet-4-20250514")
v.SetDefault("claude.max_tokens", 4096)
v.SetDefault("claude.temperature", 0.3)
v.SetDefault("deepseek.base_url", "https://api.deepseek.com")
v.SetDefault("deepseek.model", "deepseek-chat")
v.SetDefault("deepseek.max_tokens", 4096)
v.SetDefault("deepseek.temperature", 0.3)
v.SetDefault("api.enabled", true)
v.SetDefault("api.port", 8080)
v.SetDefault("logging.level", "INFO")
v.SetDefault("logging.format", "json")
}
func setSiteDefaultsMulti(v *viper.Viper) {
v.SetDefault("wordpress.timeout", "30s")
v.SetDefault("runtime.dry_run", false)
v.SetDefault("scheduler.enabled", true)
v.SetDefault("scheduler.interval_hours", 2.5)
v.SetDefault("scheduler.languages", []string{"en", "es"})
v.SetDefault("optimization.min_content_length", 500)
v.SetDefault("optimization.max_age_days", 365)
v.SetDefault("optimization.min_age_days", 30)
v.SetDefault("optimization.reoptimization_cooldown_days", 180)
v.SetDefault("optimization.max_internal_links", 8)
v.SetDefault("optimization.faq_min_questions", 4)
v.SetDefault("optimization.faq_max_questions", 6)
v.SetDefault("optimization.sync_translations", true)
v.SetDefault("ideas.enabled", true)
v.SetDefault("ideas.interval_hours", 24.0)
v.SetDefault("ideas.subreddit", "kubernetes")
v.SetDefault("ideas.listing", "top")
v.SetDefault("ideas.time_range", "day")
v.SetDefault("ideas.max_reddit_posts", 20)
v.SetDefault("ideas.max_ideas", 5)
v.SetDefault("ideas.style_sample_posts", 3)
v.SetDefault("ideas.min_score", 25)
v.SetDefault("ideas.min_comments", 5)
v.SetDefault("outreach.enabled", true)
v.SetDefault("outreach.subreddit", "kubernetes")
v.SetDefault("outreach.listing", "top")
v.SetDefault("outreach.time_range", "day")
v.SetDefault("outreach.max_reddit_posts", 20)
v.SetDefault("outreach.max_suggestions", 5)
v.SetDefault("outreach.min_score", 25)
v.SetDefault("outreach.min_comments", 5)
v.SetDefault("outreach.max_articles", 50)
v.SetDefault("newsletter.enabled", true)
v.SetDefault("newsletter.interval_hours", 168.0)
v.SetDefault("newsletter.feed_url", "https://alexandre-vazquez.com/feed/")
v.SetDefault("newsletter.subreddit", "kubernetes")
v.SetDefault("newsletter.listing", "top")
v.SetDefault("newsletter.time_range", "week")
v.SetDefault("newsletter.max_feed_items", 5)
v.SetDefault("newsletter.max_reddit_posts", 10)
v.SetDefault("newsletter.min_score", 10)
v.SetDefault("newsletter.min_comments", 2)
v.SetDefault("image_audit.enabled", true)
v.SetDefault("image_audit.interval_hours", 168.0)
v.SetDefault("image_audit.base_url", "")
v.SetDefault("image_audit.max_posts", 0)
v.SetDefault("image_audit.max_images_per_post", 0)
v.SetDefault("image_audit.allowed_host_suffixes", []string{"optimole.com"})
v.SetDefault("image_audit.download_timeout", "20s")
v.SetDefault("image_audit.max_download_bytes", int64(20*1024*1024))
v.SetDefault("seo_audit.enabled", true)
v.SetDefault("seo_audit.base_url", "")
v.SetDefault("seo_audit.max_posts", 0)
v.SetDefault("link_checker.enabled", true)
v.SetDefault("link_checker.interval_hours", 168.0)
v.SetDefault("link_checker.request_timeout", "10s")
v.SetDefault("link_checker.max_posts", 0)
v.SetDefault("link_checker.max_links_per_post", 0)
v.SetDefault("link_checker.concurrency", 6)
}
func applySiteDefaultsMulti(site *SiteConfig) {
if strings.TrimSpace(site.Profile.Name) == "" {
site.Profile.Name = deriveSiteName(site.WordPress.BaseURL)
}
if strings.TrimSpace(site.Profile.URL) == "" {
site.Profile.URL = strings.TrimRight(site.WordPress.BaseURL, "/")
}
}
func normalizeGlobal(cfg *GlobalConfig) {
cfg.LLM.Provider = strings.ToLower(strings.TrimSpace(cfg.LLM.Provider))
if cfg.LLM.Provider == "" {
cfg.LLM.Provider = "claude"
}
}
func expandGlobalEnv(cfg *GlobalConfig) {
cfg.Claude.APIKey = os.ExpandEnv(cfg.Claude.APIKey)
cfg.DeepSeek.APIKey = os.ExpandEnv(cfg.DeepSeek.APIKey)
cfg.DeepSeek.BaseURL = os.ExpandEnv(cfg.DeepSeek.BaseURL)
}
func flattenSettings(prefix string, settings map[string]interface{}, v *viper.Viper) {
for key, value := range settings {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch typed := value.(type) {
case map[string]interface{}:
flattenSettings(fullKey, typed, v)
default:
v.Set(fullKey, typed)
}
}
}
func deriveSiteName(baseURL string) string {
trimmed := strings.TrimSpace(strings.TrimSuffix(baseURL, "/"))
if trimmed == "" {
return "WPSidekick"
}
trimmed = strings.TrimPrefix(trimmed, "https://")
trimmed = strings.TrimPrefix(trimmed, "http://")
return trimmed
}
+132
View File
@@ -0,0 +1,132 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeTempConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
return path
}
func TestLoadConfigSetMulti(t *testing.T) {
t.Setenv("CLAUDE_API_KEY", "test-claude")
t.Setenv("WP_APP_PASSWORD_AV", "app-pass-av")
t.Setenv("WP_APP_PASSWORD_PADRE", "app-pass-padre")
configPath := writeTempConfig(t, `
global:
llm:
provider: "claude"
claude:
api_key: "${CLAUDE_API_KEY}"
model: "claude-sonnet-4-20250514"
max_tokens: 4096
temperature: 0.3
api:
enabled: true
port: 9090
logging:
level: "INFO"
format: "json"
default_site: "av"
sites:
- id: "av"
name: "Alexandre Vazquez"
data_base_path: "./data/sites/av"
wordpress:
base_url: "https://alexandre-vazquez.com"
username: "admin"
app_password: "${WP_APP_PASSWORD_AV}"
scheduler:
languages: ["en", "es"]
- id: "padre"
name: "Padre Primerizo"
data_base_path: "./data/sites/padre"
wordpress:
base_url: "https://padreprimerizo.com"
username: "editor"
app_password: "${WP_APP_PASSWORD_PADRE}"
scheduler:
languages: ["es"]
newsletter:
enabled: false
`)
set, err := LoadConfigSet(configPath)
if err != nil {
t.Fatalf("LoadConfigSet failed: %v", err)
}
if set.DefaultSite != "av" {
t.Fatalf("expected default_site av, got %q", set.DefaultSite)
}
if len(set.Sites) != 2 {
t.Fatalf("expected 2 sites, got %d", len(set.Sites))
}
var padre *SiteDefinition
for i := range set.Sites {
if set.Sites[i].ID == "padre" {
padre = &set.Sites[i]
break
}
}
if padre == nil {
t.Fatalf("padre site not found")
}
if padre.Config.Newsletter.Enabled {
t.Fatalf("expected newsletter disabled for padre")
}
gotIdeas := filepath.Clean(padre.Config.Ideas.BasePath)
expectedIdeas := filepath.Clean("./data/sites/padre/ideas")
if gotIdeas != expectedIdeas {
t.Fatalf("expected ideas base path %q, got %q", expectedIdeas, gotIdeas)
}
gotStorage := filepath.Clean(padre.Config.Storage.BasePath)
expectedStorage := filepath.Clean("./data/sites/padre/optimizations")
if gotStorage != expectedStorage {
t.Fatalf("expected storage base path %q, got %q", expectedStorage, gotStorage)
}
}
func TestLoadConfigSetLegacy(t *testing.T) {
t.Setenv("CLAUDE_API_KEY", "test-claude")
t.Setenv("WP_APP_PASSWORD", "legacy-pass")
configPath := writeTempConfig(t, `
wordpress:
base_url: "https://example.com"
username: "admin"
app_password: "${WP_APP_PASSWORD}"
llm:
provider: "claude"
claude:
api_key: "${CLAUDE_API_KEY}"
model: "claude-sonnet-4-20250514"
`)
set, err := LoadConfigSet(configPath)
if err != nil {
t.Fatalf("LoadConfigSet failed: %v", err)
}
if len(set.Sites) != 1 {
t.Fatalf("expected 1 site, got %d", len(set.Sites))
}
if set.DefaultSite != "default" {
t.Fatalf("expected default site to be default, got %q", set.DefaultSite)
}
if set.Sites[0].Config.Profile.Name == "" {
t.Fatalf("expected profile name to be set")
}
}
+26 -11
View File
@@ -18,6 +18,11 @@ type IdeaPromptData struct {
MaxIdeas int
RedditPosts []RedditPromptPost
StyleSamples []StyleSample
SiteName string
SiteURL string
SiteTopics string
SiteAudience string
SiteVoice string
}
type RedditPromptPost struct {
@@ -37,6 +42,12 @@ type ArticlePromptData struct {
PrimaryKW string
Sources []string
StyleSamples []StyleSample
SiteName string
SiteURL string
SiteTopics string
SiteAudience string
SiteVoice string
ContentGuidelines []string
}
type OutreachPromptData struct {
@@ -52,10 +63,10 @@ type ArticleRef struct {
Excerpt string
}
const ideaPromptTemplate = `You are an expert SEO editor for a technical blog about Kubernetes, DevOps, platform engineering, and enterprise infrastructure.
const ideaPromptTemplate = `You are an expert SEO editor for {{.SiteName}} ({{.SiteURL}}), a blog about {{.SiteTopics}} for {{.SiteAudience}}.
Your task: propose {{.MaxIdeas}} SEO-focused post ideas based on the Reddit discussions listed below.
Each idea must be specific, technically accurate, and aligned with the author's tone and depth.
Each idea must be specific, accurate, and aligned with the site's voice ({{.SiteVoice}}).
Style references (recent posts from the blog):
{{range .StyleSamples -}}
@@ -75,7 +86,7 @@ Output requirements:
- Return ONLY valid JSON (no markdown)
- Keep each idea unique and grounded in at least one Reddit source
- Provide a primary keyword for SEO
- Provide 1-2 additional authoritative references (official docs, CNCF, Kubernetes, etc.)
- Provide 1-2 additional authoritative references relevant to the topic
JSON format:
{
@@ -99,9 +110,12 @@ JSON format:
]
}`
const articlePromptTemplate = `You are a senior technical writer for alexandre-vazquez.com.
const articlePromptTemplate = `You are a senior writer for {{.SiteName}} ({{.SiteURL}}).
Write a full blog post in {{.Language}} that matches the author's style, depth, and structure.
Write a full blog post in {{.Language}} that matches the site's style, depth, and structure.
Audience: {{.SiteAudience}}
Voice: {{.SiteVoice}}
Topics: {{.SiteTopics}}
Use the idea below and the sources provided. The output must be production-ready HTML.
Idea:
@@ -121,14 +135,15 @@ Requirements:
- Return ONLY valid JSON (no markdown).
- Title and SEO title must be compelling and keyword-forward (50-60 chars).
- Meta description: 150-160 chars, actionable and keyword-rich.
- Content: 1,200-2,000 words, technical depth, includes H2/H3 headings.
- Include 3-5 bullet lists where appropriate.
- Include 1 short code block relevant to the topic.
- Do not fabricate benchmarks or version numbers.
- Use clear structure with H2/H3 headings.
- Include bullet lists where appropriate.
- Do not fabricate facts or statistics.
- Include citations as inline links (HTML <a href>) only if they are provided in sources or references.
- After generating content, review it to ensure Gutenberg block compatibility (tables, headings, images, code blocks). Fix structural issues such as headings without proper tags, stray phrases inside headings, malformed tables/images. Keep code blocks coherent.
- Never translate code or technical terminology (objects, products, techniques). Examples: Kubernetes objects, Helm, TLS edge-termination.
{{if eq .Language "Spanish"}}- Keep technical terms and product names in English (e.g., Kubernetes, Helm, Ingress, CRD, ServiceAccount). Do not translate them.{{end}}
{{if .ContentGuidelines}}
Additional site guidelines:
{{range .ContentGuidelines}}- {{.}}
{{end}}{{end}}
JSON format:
{
+46
View File
@@ -0,0 +1,46 @@
package ideas
import (
"strings"
"testing"
)
func TestBuildIdeaPromptIncludesSiteContext(t *testing.T) {
prompt, err := BuildIdeaPrompt(IdeaPromptData{
Subreddit: "paternidad",
MaxIdeas: 3,
SiteName: "Padre Primerizo",
SiteURL: "https://padreprimerizo.com",
SiteTopics: "Paternidad, Crianza",
SiteAudience: "Padres primerizos",
SiteVoice: "Cercano",
})
if err != nil {
t.Fatalf("BuildIdeaPrompt failed: %v", err)
}
if !strings.Contains(prompt, "Padre Primerizo") {
t.Fatalf("expected prompt to include site name")
}
}
func TestBuildArticlePromptIncludesGuidelines(t *testing.T) {
prompt, err := BuildArticlePrompt(ArticlePromptData{
Language: "Spanish",
IdeaTitle: "Idea",
IdeaSummary: "Summary",
PrimaryKW: "keyword",
Sources: []string{"https://example.com"},
SiteName: "Padre Primerizo",
SiteURL: "https://padreprimerizo.com",
SiteTopics: "Paternidad",
SiteAudience: "Padres",
SiteVoice: "Cercano",
ContentGuidelines: []string{"Guia 1", "Guia 2"},
})
if err != nil {
t.Fatalf("BuildArticlePrompt failed: %v", err)
}
if !strings.Contains(prompt, "Guia 1") || !strings.Contains(prompt, "Guia 2") {
t.Fatalf("expected prompt to include content guidelines")
}
}
+119 -63
View File
@@ -26,6 +26,8 @@ type Service struct {
outreach *storage.OutreachStorage
cfg config.IdeasConfig
outreachCfg config.OutreachConfig
profile config.SiteProfile
languages []string
dryRun bool
logger *logger.Logger
}
@@ -38,6 +40,8 @@ func NewService(
cfg config.IdeasConfig,
outreachStore *storage.OutreachStorage,
outreachCfg config.OutreachConfig,
profile config.SiteProfile,
languages []string,
dryRun bool,
log *logger.Logger,
) *Service {
@@ -56,6 +60,8 @@ func NewService(
cfg: cfg,
outreach: outreachStore,
outreachCfg: outreachCfg,
profile: profile,
languages: normalizeLanguages(languages),
dryRun: dryRun,
logger: log.WithComponent("ideas"),
}
@@ -94,6 +100,11 @@ func (s *Service) GenerateDailyIdeas(ctx context.Context) ([]*models.PostIdea, e
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 {
@@ -291,81 +302,90 @@ func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostId
sourceURLs := ideaSourceURLs(idea)
enPrompt, err := BuildArticlePrompt(ArticlePromptData{
Language: "English",
IdeaTitle: idea.SEOTitle,
IdeaSummary: idea.Summary,
PrimaryKW: idea.PrimaryKW,
Sources: sourceURLs,
StyleSamples: styleSamples,
})
if err != nil {
return nil, fmt.Errorf("failed to build EN article prompt: %w", err)
langs := s.languages
if len(langs) == 0 {
langs = []string{"en", "es"}
}
esPrompt, err := BuildArticlePrompt(ArticlePromptData{
Language: "Spanish",
IdeaTitle: idea.SEOTitle,
IdeaSummary: idea.Summary,
PrimaryKW: idea.PrimaryKW,
Sources: sourceURLs,
StyleSamples: styleSamples,
})
if err != nil {
return nil, fmt.Errorf("failed to build ES article prompt: %w", err)
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
}
enDraft, err := s.llm.GenerateArticleDraft(ctx, enPrompt)
if err != nil {
return nil, err
if len(drafts) == 0 {
return nil, fmt.Errorf("no draft languages available")
}
enDraft.Language = "en"
esDraft, err := s.llm.GenerateArticleDraft(ctx, esPrompt)
if err != nil {
return nil, err
}
esDraft.Language = "es"
enID := 0
esID := 0
if s.dryRun {
s.logger.Info("Dry-run enabled, skipping WordPress draft creation for idea")
} else {
var err error
enID, err = s.wpClient.CreateDraftPost(ctx, wordpress.PostCreate{
Status: "draft",
Title: enDraft.Title,
Content: enDraft.ContentHTML,
Excerpt: chooseExcerpt(enDraft),
Lang: "en",
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: enDraft.SEOTitle,
wordpress.MetaRankMathDescription: enDraft.MetaDescription,
},
})
if err != nil {
return nil, fmt.Errorf("failed to create EN draft: %w", err)
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)
}
}
esID, err = s.wpClient.CreateDraftPost(ctx, wordpress.PostCreate{
Status: "draft",
Title: esDraft.Title,
Content: esDraft.ContentHTML,
Excerpt: chooseExcerpt(esDraft),
Lang: "es",
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: esDraft.SEOTitle,
wordpress.MetaRankMathDescription: esDraft.MetaDescription,
},
})
if err != nil {
return nil, fmt.Errorf("failed to create ES 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 = enDraft
idea.ArticleES = esDraft
idea.ArticleEN = drafts["en"]
idea.ArticleES = drafts["es"]
idea.DraftPostID = enID
idea.DraftPostIDEs = esID
idea.Status = "drafted"
@@ -391,12 +411,14 @@ func (s *Service) PublishDrafts(ctx context.Context, id string) (*models.PostIde
if s.dryRun {
return nil, fmt.Errorf("dry-run enabled: publishing is disabled")
}
if idea.DraftPostID <= 0 {
return nil, fmt.Errorf("idea has no draft post")
if idea.DraftPostID <= 0 && idea.DraftPostIDEs <= 0 {
return nil, fmt.Errorf("idea has no draft posts")
}
if err := s.wpClient.UpdatePostStatus(ctx, idea.DraftPostID, "publish"); err != nil {
return nil, fmt.Errorf("failed to publish EN draft: %w", err)
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 {
@@ -457,6 +479,40 @@ func ideaSourceURLs(idea *models.PostIdea) []string {
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 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
+157
View File
@@ -0,0 +1,157 @@
package ideas
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/config"
"seo-optimizer/internal/reddit"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
type fakeLLM struct{}
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) {
return []*models.PostIdea{
{
SEOTitle: "Idea SEO",
Summary: "Summary",
PrimaryKW: "keyword",
Sources: []models.IdeaSource{{
Title: "Reddit",
URL: "https://reddit.com/r/test",
}},
References: []string{"https://example.com"},
},
}, nil
}
func (f *fakeLLM) GenerateArticleDraft(ctx context.Context, prompt string) (*models.ArticleDraft, error) {
return &models.ArticleDraft{
Title: "Draft",
SEOTitle: "SEO Draft",
MetaDescription: "Meta",
Excerpt: "Excerpt",
ContentHTML: "<p>Content</p>",
}, nil
}
func (f *fakeLLM) GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error) {
return nil, nil
}
func (f *fakeLLM) GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error) {
return nil, nil
}
func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) {
// Reddit server
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": "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,
}},
},
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer redditServer.Close()
// WordPress server for style samples
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": 1,
"title": map[string]interface{}{"rendered": "Sample"},
"excerpt": map[string]interface{}{"rendered": "Excerpt"},
"content": map[string]interface{}{"rendered": "Content"},
},
}
_ = 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)
}
cfg := config.IdeasConfig{
Enabled: true,
IntervalHours: 24,
Subreddit: "paternidad",
Listing: "top",
TimeRange: "day",
MaxRedditPosts: 5,
MaxIdeas: 1,
StyleSamplePosts: 1,
MinScore: 10,
MinComments: 1,
BasePath: t.TempDir(),
}
outreachCfg := config.OutreachConfig{
Enabled: false,
}
profile := config.SiteProfile{
Name: "Padre Primerizo",
URL: "https://padreprimerizo.com",
Topics: []string{"Paternidad"},
Audience: "Padres",
Voice: "Cercano",
}
svc := NewService(wpClient, &fakeLLM{}, redditClient, ideasStore, cfg, outreachStore, outreachCfg, profile, []string{"es"}, true, nil)
ideas, err := svc.GenerateDailyIdeas(context.Background())
if err != nil {
t.Fatalf("GenerateDailyIdeas failed: %v", err)
}
if len(ideas) != 1 {
t.Fatalf("expected 1 idea, got %d", len(ideas))
}
idea, err := svc.GenerateDrafts(context.Background(), ideas[0].ID)
if err != nil {
t.Fatalf("GenerateDrafts failed: %v", err)
}
if idea.ArticleES == nil {
t.Fatalf("expected Spanish draft to be generated")
}
if idea.ArticleEN != nil {
t.Fatalf("expected no English draft for es-only site")
}
}
+73
View File
@@ -0,0 +1,73 @@
package newsletter
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/config"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
type fakeLLM struct{}
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) {
return nil, nil
}
func (f *fakeLLM) GenerateArticleDraft(ctx context.Context, prompt string) (*models.ArticleDraft, error) {
return nil, nil
}
func (f *fakeLLM) GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error) {
return nil, nil
}
func (f *fakeLLM) GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error) {
return &models.NewsletterDraft{
Subject: "Weekly",
PreviewText: "Preview",
BodyHTML: "<p>Hello</p>",
BodyText: "Hello",
}, nil
}
func TestGenerateWeeklyDraft(t *testing.T) {
feed := `<?xml version="1.0"?><rss><channel><item><title>Post 1</title><link>https://example.com/1</link><description>Desc</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate></item></channel></rss>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(feed))
}))
defer server.Close()
cfg := config.NewsletterConfig{
Enabled: true,
FeedURL: server.URL,
MaxFeedItems: 1,
MaxRedditPosts: 0,
}
store := storage.NewNewsletterStorage(t.TempDir(), nil)
if err := store.Initialize(); err != nil {
t.Fatalf("failed to init storage: %v", err)
}
svc := NewService(&fakeLLM{}, nil, store, cfg, nil)
draft, err := svc.GenerateWeeklyDraft(context.Background())
if err != nil {
t.Fatalf("GenerateWeeklyDraft failed: %v", err)
}
if draft.ID == "" {
t.Fatalf("expected draft ID to be set")
}
if draft.Status != "draft" {
t.Fatalf("expected draft status to be draft")
}
}
+57
View File
@@ -0,0 +1,57 @@
package reddit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestFetchPostsParsesListing(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
resp := map[string]interface{}{
"data": map[string]interface{}{
"children": []map[string]interface{}{
{"data": map[string]interface{}{
"title": "Post A",
"url": "https://example.com/a",
"permalink": "/r/test/comments/a",
"selftext": "Body",
"author": "user",
"score": 42,
"num_comments": 10,
"created_utc": 1710000000,
"subreddit": "test",
"is_self": true,
"stickied": false,
}},
{"data": map[string]interface{}{
"title": "Sticky",
"url": "https://example.com/sticky",
"permalink": "/r/test/comments/sticky",
"stickied": true,
}},
},
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL, nil)
posts, err := client.FetchPosts(context.Background(), "test", "top", "day", 10)
if err != nil {
t.Fatalf("FetchPosts failed: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post after filtering stickied, got %d", len(posts))
}
if posts[0].Title != "Post A" {
t.Fatalf("unexpected title: %s", posts[0].Title)
}
}
+1
View File
@@ -259,6 +259,7 @@ func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, lang
}
optimization, err := o.llmClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap, &agent.OptimizeOptions{
Feedback: feedback,
Profile: o.config.Profile,
})
if err != nil {
return fmt.Errorf("llm optimization failed: %w", err)
+95
View File
@@ -0,0 +1,95 @@
package wordpress
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestListPosts(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/wp-json/wp/v2/posts" {
w.WriteHeader(http.StatusNotFound)
return
}
posts := []map[string]interface{}{
{
"id": 1,
"title": map[string]interface{}{"rendered": "Title"},
"content": map[string]interface{}{"rendered": "Content"},
},
}
_ = json.NewEncoder(w).Encode(posts)
}))
defer server.Close()
client := NewClient(server.URL, "user", "pass", 0, nil)
posts, err := client.ListPosts(context.Background(), ListParams{Status: "publish", PerPage: 1})
if err != nil {
t.Fatalf("ListPosts failed: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Title.Rendered != "Title" {
t.Fatalf("unexpected title %q", posts[0].Title.Rendered)
}
}
func TestCreateDraftPost(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/wp-json/wp/v2/posts" {
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
if !json.Valid(body) {
w.WriteHeader(http.StatusBadRequest)
return
}
resp := map[string]interface{}{"id": 99}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL, "user", "pass", 0, nil)
id, err := client.CreateDraftPost(context.Background(), PostCreate{Title: "Draft"})
if err != nil {
t.Fatalf("CreateDraftPost failed: %v", err)
}
if id != 99 {
t.Fatalf("expected id 99, got %d", id)
}
}
func TestUpdatePostStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/wp-json/wp/v2/posts/10" {
w.WriteHeader(http.StatusNotFound)
return
}
resp := map[string]interface{}{"id": 10, "title": map[string]interface{}{"rendered": "Title"}, "slug": "slug"}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL, "user", "pass", 0, nil)
if err := client.UpdatePostStatus(context.Background(), 10, "publish"); err != nil {
t.Fatalf("UpdatePostStatus failed: %v", err)
}
}