initial version

This commit is contained in:
2026-01-25 08:57:55 +01:00
commit a538af46ce
33 changed files with 6662 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type client struct {
baseURL string
http *http.Client
}
func newClient(baseURL string) *client {
return &client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *client) do(method, path string, payload interface{}) ([]byte, int, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, c.baseURL+path, body)
if err != nil {
return nil, 0, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return respBody, resp.StatusCode, nil
}
func main() {
defaultAPI := os.Getenv("WPSK_API_URL")
if defaultAPI == "" {
defaultAPI = "http://localhost:8080"
}
apiURL := flag.String("api", defaultAPI, "API base URL")
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, " 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, " help")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " wp-sk-cli pending")
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")
}
flag.Usage = usage
flag.Parse()
args := flag.Args()
if len(args) == 0 {
usage()
os.Exit(1)
}
cmd := args[0]
args = args[1:]
if cmd == "help" || cmd == "-h" || cmd == "--help" {
usage()
return
}
c := newClient(*apiURL)
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()
os.Exit(1)
}
}
func handleSimpleGet(c *client, path string) {
body, status, err := c.do(http.MethodGet, path, nil)
handleResponse(body, status, err)
}
func handleSimplePost(c *client, path string, payload interface{}) {
body, status, err := c.do(http.MethodPost, path, payload)
handleResponse(body, status, err)
}
func handleResponse(body []byte, status int, err error) {
if err != nil {
die(err.Error())
}
if status < 200 || status >= 300 {
fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status)
fmt.Fprintln(os.Stderr, string(body))
os.Exit(1)
}
var pretty bytes.Buffer
if json.Indent(&pretty, body, "", " ") == nil {
fmt.Println(pretty.String())
return
}
fmt.Println(string(body))
}
type pendingResponse struct {
Count int `json:"count"`
Records []pendingRecord `json:"records"`
}
type pendingRecord struct {
OriginalPostID int `json:"original_post_id"`
DraftPostID int `json:"draft_post_id"`
PostTitle string `json:"post_title"`
}
func handlePendingSummary(c *client) {
body, status, err := c.do(http.MethodGet, "/api/v1/pending", nil)
if err != nil {
die(err.Error())
}
if status < 200 || status >= 300 {
fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status)
fmt.Fprintln(os.Stderr, string(body))
os.Exit(1)
}
var resp pendingResponse
if err := json.Unmarshal(body, &resp); err != nil {
die("failed to parse response")
}
if resp.Count == 0 || len(resp.Records) == 0 {
fmt.Println("No pending records.")
return
}
for _, record := range resp.Records {
if record.OriginalPostID > 0 {
fmt.Printf("%d\t%s (original %d)\n", record.DraftPostID, record.PostTitle, record.OriginalPostID)
} else {
fmt.Printf("%d\t%s\n", record.DraftPostID, record.PostTitle)
}
}
}
func parseIntArg(name, value string) (int, error) {
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer", name)
}
return parsed, nil
}
func die(msg string) {
fmt.Fprintln(os.Stderr, msg)
os.Exit(1)
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/api"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/scheduler"
"seo-optimizer/internal/seo"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
)
const version = "1.0.0"
func main() {
// Load configuration
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "configs/config.yaml"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
// Initialize logger
log := logger.NewLogger(cfg.Logging.Level, cfg.Logging.Format)
log.Info("SEO Optimizer starting",
"version", version,
"config", configPath,
)
// 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,
)
// 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 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 optimizer
optimizer := seo.NewOptimizer(wpClient, claudeClient, store, cfg, log)
log.Info("Optimizer initialized")
// Context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start API server if enabled
var apiServer *api.Server
if cfg.API.Enabled {
apiServer = api.NewServer(optimizer, wpClient, store, cfg.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,
)
}
// Wait for interrupt signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigCh
log.Info("Shutdown signal received", "signal", sig.String())
// Graceful shutdown
log.Info("Initiating graceful shutdown...")
// Cancel context to stop scheduler
cancel()
// Shutdown API server if running
if apiServer != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := apiServer.Shutdown(shutdownCtx); err != nil {
log.Error("API server shutdown error", "error", err)
} else {
log.Info("API server shut down successfully")
}
}
// Stop scheduler
if sched != nil {
sched.Stop()
log.Info("Scheduler stopped")
}
log.Info("SEO Optimizer stopped gracefully")
}