+1
This commit is contained in:
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+115
-64
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user