This commit is contained in:
2026-01-26 10:06:53 +01:00
parent b1397b7510
commit ec82340f42
38 changed files with 6027 additions and 192 deletions
+342 -61
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
@@ -13,6 +14,8 @@ import (
"time"
)
const version = "1.0.0"
type client struct {
baseURL string
http *http.Client
@@ -44,6 +47,7 @@ func (c *client) do(method, path string, payload interface{}) ([]byte, int, erro
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-WPSK-Client", "cli")
resp, err := c.http.Do(req)
if err != nil {
@@ -65,16 +69,31 @@ func main() {
defaultAPI = "http://localhost:8080"
}
apiURL := flag.String("api", defaultAPI, "API base URL")
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.Fprintln(os.Stderr, "Commands:")
fmt.Fprintln(os.Stderr, " health")
fmt.Fprintln(os.Stderr, " pending [summary]")
fmt.Fprintln(os.Stderr, " pending [summary|text <draft_post_id>]")
fmt.Fprintln(os.Stderr, " optimize <post_id> [language]")
fmt.Fprintln(os.Stderr, " status <job_id>")
fmt.Fprintln(os.Stderr, " changes <post_id>")
fmt.Fprintln(os.Stderr, " approve <draft_post_id>")
fmt.Fprintln(os.Stderr, " reject <draft_post_id>")
fmt.Fprintln(os.Stderr, " ideas [status]")
fmt.Fprintln(os.Stderr, " ideas generate")
fmt.Fprintln(os.Stderr, " ideas draft <idea_id>")
fmt.Fprintln(os.Stderr, " ideas publish <idea_id>")
fmt.Fprintln(os.Stderr, " ideas delete <idea_id>")
fmt.Fprintln(os.Stderr, " outreach [status]")
fmt.Fprintln(os.Stderr, " outreach generate")
fmt.Fprintln(os.Stderr, " outreach delete <id>")
fmt.Fprintln(os.Stderr, " newsletter [status]")
fmt.Fprintln(os.Stderr, " newsletter generate")
fmt.Fprintln(os.Stderr, " newsletter delete <id>")
fmt.Fprintln(os.Stderr, " audit [limit]")
fmt.Fprintln(os.Stderr, " version")
fmt.Fprintln(os.Stderr, " shell")
fmt.Fprintln(os.Stderr, " help")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
@@ -86,6 +105,11 @@ func main() {
flag.Usage = usage
flag.Parse()
if *versionFlag {
fmt.Println(version)
return
}
args := flag.Args()
if len(args) == 0 {
usage()
@@ -100,67 +124,17 @@ func main() {
}
c := newClient(*apiURL)
if cmd != "version" && cmd != "help" {
warnPendingOutreach(c)
}
switch cmd {
case "health":
handleSimpleGet(c, "/api/v1/health")
case "pending":
if len(args) > 0 && args[0] == "summary" {
handlePendingSummary(c)
return
}
handleSimpleGet(c, "/api/v1/pending")
case "status":
if len(args) < 1 {
die("status requires <job_id>")
}
handleSimpleGet(c, "/api/v1/status/"+args[0])
case "changes":
if len(args) < 1 {
die("changes requires <post_id>")
}
handleSimpleGet(c, "/api/v1/optimization/"+args[0])
case "optimize":
if len(args) < 1 {
die("optimize requires <post_id>")
}
postID, err := parseIntArg("post_id", args[0])
if err != nil {
die(err.Error())
}
language := "en"
if len(args) > 1 {
language = args[1]
}
handleSimplePost(c, "/api/v1/optimize", map[string]interface{}{
"post_id": postID,
"language": language,
})
case "approve":
if len(args) < 1 {
die("approve requires <draft_post_id>")
}
draftID, err := parseIntArg("draft_post_id", args[0])
if err != nil {
die(err.Error())
}
handleSimplePost(c, "/api/v1/apply-draft", map[string]interface{}{
"draft_post_id": draftID,
})
case "reject":
if len(args) < 1 {
die("reject requires <draft_post_id>")
}
draftID, err := parseIntArg("draft_post_id", args[0])
if err != nil {
die(err.Error())
}
handleSimplePost(c, "/api/v1/reject-draft", map[string]interface{}{
"draft_post_id": draftID,
})
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd)
usage()
if cmd == "shell" {
runInteractive(c)
return
}
if err := executeCommand(c, cmd, args); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
@@ -234,6 +208,273 @@ func handlePendingSummary(c *client) {
}
}
func executeCommand(c *client, cmd string, args []string) error {
switch cmd {
case "health":
handleSimpleGet(c, "/api/v1/health")
case "version":
return handleVersion(c)
case "pending":
if len(args) > 0 && args[0] == "summary" {
handlePendingSummary(c)
return nil
}
if len(args) > 0 && args[0] == "text" {
if len(args) < 2 {
return fmt.Errorf("pending text requires <draft_post_id>")
}
draftID, err := parseIntArg("draft_post_id", args[1])
if err != nil {
return err
}
return handleDraftText(c, draftID)
}
handleSimpleGet(c, "/api/v1/pending")
case "status":
if len(args) < 1 {
return fmt.Errorf("status requires <job_id>")
}
handleSimpleGet(c, "/api/v1/status/"+args[0])
case "changes":
if len(args) < 1 {
return fmt.Errorf("changes requires <post_id>")
}
handleSimpleGet(c, "/api/v1/optimization/"+args[0])
case "optimize":
if len(args) < 1 {
return fmt.Errorf("optimize requires <post_id>")
}
postID, err := parseIntArg("post_id", args[0])
if err != nil {
return err
}
language := "en"
if len(args) > 1 {
language = args[1]
}
handleSimplePost(c, "/api/v1/optimize", map[string]interface{}{
"post_id": postID,
"language": language,
})
case "approve":
if len(args) < 1 {
return fmt.Errorf("approve requires <draft_post_id>")
}
draftID, err := parseIntArg("draft_post_id", args[0])
if err != nil {
return err
}
handleSimplePost(c, "/api/v1/apply-draft", map[string]interface{}{
"draft_post_id": draftID,
})
case "reject":
if len(args) < 1 {
return fmt.Errorf("reject requires <draft_post_id>")
}
draftID, err := parseIntArg("draft_post_id", args[0])
if err != nil {
return err
}
handleSimplePost(c, "/api/v1/reject-draft", map[string]interface{}{
"draft_post_id": draftID,
})
case "ideas":
if len(args) == 0 {
handleSimpleGet(c, "/api/v1/ideas")
return nil
}
switch args[0] {
case "generate":
handleSimplePost(c, "/api/v1/ideas/generate", nil)
case "draft":
if len(args) < 2 {
return fmt.Errorf("ideas draft requires <idea_id>")
}
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/draft", nil)
case "publish":
if len(args) < 2 {
return fmt.Errorf("ideas publish requires <idea_id>")
}
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/publish", nil)
case "delete":
if len(args) < 2 {
return fmt.Errorf("ideas delete requires <idea_id>")
}
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/delete", nil)
default:
status := args[0]
handleSimpleGet(c, "/api/v1/ideas?status="+status)
}
case "outreach":
if len(args) == 0 {
handleSimpleGet(c, "/api/v1/outreach")
return nil
}
switch args[0] {
case "generate":
handleSimplePost(c, "/api/v1/outreach/generate", nil)
case "delete":
if len(args) < 2 {
return fmt.Errorf("outreach delete requires <id>")
}
handleSimplePost(c, "/api/v1/outreach/"+args[1]+"/delete", nil)
default:
status := args[0]
handleSimpleGet(c, "/api/v1/outreach?status="+status)
}
case "newsletter":
if len(args) == 0 {
handleSimpleGet(c, "/api/v1/newsletter")
return nil
}
switch args[0] {
case "generate":
handleSimplePost(c, "/api/v1/newsletter/generate", nil)
case "delete":
if len(args) < 2 {
return fmt.Errorf("newsletter delete requires <id>")
}
handleSimplePost(c, "/api/v1/newsletter/"+args[1]+"/delete", nil)
default:
status := args[0]
handleSimpleGet(c, "/api/v1/newsletter?status="+status)
}
case "audit":
limit := 100
if len(args) > 0 {
parsed, err := strconv.Atoi(args[0])
if err != nil || parsed <= 0 {
return fmt.Errorf("audit limit must be a positive integer")
}
limit = parsed
}
handleSimpleGet(c, fmt.Sprintf("/api/v1/audit?limit=%d", limit))
default:
return fmt.Errorf("unknown command: %s", cmd)
}
return nil
}
func runInteractive(c *client) {
fmt.Println("WPSideKick interactive shell. Type 'help' or 'exit'.")
warnPendingOutreach(c)
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("wpsk> ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if line == "exit" || line == "quit" {
return
}
if line == "help" {
fmt.Println("Commands: health, version, pending [summary|text <draft_id>], optimize <id> [lang], status <job_id>, changes <post_id>, approve <draft_id>, reject <draft_id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], audit [limit], exit")
continue
}
tokens := strings.Fields(line)
if len(tokens) == 0 {
continue
}
cmd := tokens[0]
args := tokens[1:]
if err := executeCommand(c, cmd, args); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "interactive error:", err.Error())
}
}
type draftResponse struct {
ID int `json:"id"`
Status string `json:"status"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Content string `json:"content"`
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
type outreachResponse struct {
Count int `json:"count"`
}
func handleDraftText(c *client, draftID int) error {
body, status, err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/drafts/%d", draftID), nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("request failed: status %d: %s", status, string(body))
}
var resp draftResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to parse response")
}
fmt.Printf("Draft %d (%s)\n", resp.ID, resp.Status)
fmt.Println(strings.Repeat("=", 60))
fmt.Println(resp.Title)
fmt.Println(strings.Repeat("-", 60))
if strings.TrimSpace(resp.Excerpt) != "" {
fmt.Println(wrapText(stripHTML(resp.Excerpt), 100))
fmt.Println(strings.Repeat("-", 60))
}
fmt.Println(wrapText(stripHTML(resp.Content), 100))
fmt.Println()
return nil
}
func handleVersion(c *client) error {
fmt.Printf("CLI version: %s\n", version)
body, status, err := c.do(http.MethodGet, "/api/v1/health", nil)
if err != nil {
fmt.Printf("Server version: unavailable (%v)\n", err)
return nil
}
if status < 200 || status >= 300 {
fmt.Printf("Server version: unavailable (status %d)\n", status)
return nil
}
var resp healthResponse
if err := json.Unmarshal(body, &resp); err != nil {
fmt.Println("Server version: unavailable (invalid response)")
return nil
}
if resp.Version == "" {
fmt.Println("Server version: unavailable")
return nil
}
fmt.Printf("Server version: %s\n", resp.Version)
return nil
}
func warnPendingOutreach(c *client) {
body, status, err := c.do(http.MethodGet, "/api/v1/outreach?status=new", nil)
if err != nil || status < 200 || status >= 300 {
return
}
var resp outreachResponse
if err := json.Unmarshal(body, &resp); err != nil {
return
}
if resp.Count > 0 {
fmt.Fprintf(os.Stderr, "⚠ Pending outreach suggestions: %d (run `wp-sk-cli outreach`)\n", resp.Count)
}
}
func parseIntArg(name, value string) (int, error) {
parsed, err := strconv.Atoi(value)
if err != nil {
@@ -246,3 +487,43 @@ func die(msg string) {
fmt.Fprintln(os.Stderr, msg)
os.Exit(1)
}
func stripHTML(value string) string {
out := strings.Builder{}
inTag := false
for _, r := range value {
switch r {
case '<':
inTag = true
case '>':
inTag = false
default:
if !inTag {
out.WriteRune(r)
}
}
}
return strings.TrimSpace(out.String())
}
func wrapText(value string, width int) string {
if width <= 0 {
return value
}
words := strings.Fields(value)
if len(words) == 0 {
return ""
}
var lines []string
line := words[0]
for _, word := range words[1:] {
if len(line)+1+len(word) > width {
lines = append(lines, line)
line = word
} else {
line += " " + word
}
}
lines = append(lines, line)
return strings.Join(lines, "\n")
}
+109 -15
View File
@@ -12,7 +12,9 @@ import (
"seo-optimizer/internal/agent"
"seo-optimizer/internal/api"
"seo-optimizer/internal/config"
"seo-optimizer/internal/ideas"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/newsletter"
"seo-optimizer/internal/scheduler"
"seo-optimizer/internal/seo"
"seo-optimizer/internal/storage"
@@ -55,19 +57,39 @@ func main() {
"username", cfg.WordPress.Username,
)
// Initialize Claude client
claudeClient := agent.NewClaudeClient(
cfg.Claude.APIKey,
cfg.Claude.Model,
cfg.Claude.MaxTokens,
cfg.Claude.Temperature,
log,
)
log.Info("Claude client initialized",
"model", cfg.Claude.Model,
"max_tokens", cfg.Claude.MaxTokens,
)
// Initialize LLM client
var llmClient agent.LLMClient
switch cfg.LLM.Provider {
case "claude":
llmClient = agent.NewClaudeClient(
cfg.Claude.APIKey,
cfg.Claude.Model,
cfg.Claude.MaxTokens,
cfg.Claude.Temperature,
log,
)
log.Info("Claude client initialized",
"model", cfg.Claude.Model,
"max_tokens", cfg.Claude.MaxTokens,
)
case "deepseek":
llmClient = agent.NewDeepSeekClient(
cfg.DeepSeek.APIKey,
cfg.DeepSeek.BaseURL,
cfg.DeepSeek.Model,
cfg.DeepSeek.MaxTokens,
cfg.DeepSeek.Temperature,
log,
)
log.Info("DeepSeek client initialized",
"model", cfg.DeepSeek.Model,
"max_tokens", cfg.DeepSeek.MaxTokens,
"base_url", cfg.DeepSeek.BaseURL,
)
default:
log.Error("Unsupported LLM provider", "provider", cfg.LLM.Provider)
os.Exit(1)
}
// Initialize storage
store := storage.NewOptimizationStorage(cfg.Storage.BasePath, log)
@@ -77,8 +99,46 @@ func main() {
}
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 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 optimizer
optimizer := seo.NewOptimizer(wpClient, claudeClient, store, cfg, log)
optimizer := seo.NewOptimizer(wpClient, llmClient, cfg.LLM.Provider, store, cfg, log)
log.Info("Optimizer initialized")
// Context with cancellation for graceful shutdown
@@ -88,7 +148,7 @@ func main() {
// Start API server if enabled
var apiServer *api.Server
if cfg.API.Enabled {
apiServer = api.NewServer(optimizer, wpClient, store, cfg.API.Port, log)
apiServer = api.NewServer(optimizer, wpClient, store, auditStore, ideaService, newsService, version, cfg.API.Port, log)
// Start server in goroutine
go func() {
@@ -125,6 +185,30 @@ func main() {
)
}
// 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, 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, 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)
}
// Wait for interrupt signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
@@ -156,5 +240,15 @@ func main() {
log.Info("Scheduler stopped")
}
if ideaSched != nil {
ideaSched.Stop()
log.Info("Idea scheduler stopped")
}
if newsSched != nil {
newsSched.Stop()
log.Info("Newsletter scheduler stopped")
}
log.Info("SEO Optimizer stopped gracefully")
}