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
+52
View File
@@ -2,10 +2,62 @@
# Go to: Users → Profile → Application Passwords
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
# LLM provider (claude or deepseek)
LLM_PROVIDER=claude
# Claude API Key (from Anthropic Console)
# Get it from: https://console.anthropic.com/
CLAUDE_API_KEY=sk-ant-api03-...
# DeepSeek API Key (from DeepSeek Platform)
# Get it from: https://platform.deepseek.com/
DEEPSEEK_API_KEY=sk-...
# DeepSeek API Base URL
# Default: https://api.deepseek.com
DEEPSEEK_BASE_URL=https://api.deepseek.com
# Dry-run mode (skip WordPress draft creation/publishing)
DRY_RUN=false
# Ideas generation (Reddit)
IDEAS_ENABLED=true
IDEAS_INTERVAL_HOURS=24
IDEAS_SUBREDDIT=kubernetes
IDEAS_LISTING=top
IDEAS_TIME_RANGE=day
IDEAS_MAX_REDDIT_POSTS=20
IDEAS_MAX_IDEAS=5
IDEAS_STYLE_SAMPLES=3
IDEAS_MIN_SCORE=25
IDEAS_MIN_COMMENTS=5
IDEAS_STORAGE_PATH=./data/ideas
# Outreach suggestions (Reddit)
OUTREACH_ENABLED=true
OUTREACH_SUBREDDIT=kubernetes
OUTREACH_LISTING=top
OUTREACH_TIME_RANGE=day
OUTREACH_MAX_REDDIT_POSTS=20
OUTREACH_MAX_SUGGESTIONS=5
OUTREACH_MIN_SCORE=25
OUTREACH_MIN_COMMENTS=5
OUTREACH_MAX_ARTICLES=50
OUTREACH_STORAGE_PATH=./data/outreach
# Newsletter drafts (RSS + Reddit)
NEWSLETTER_ENABLED=true
NEWSLETTER_INTERVAL_HOURS=168
NEWSLETTER_FEED_URL=https://alexandre-vazquez.com/feed/
NEWSLETTER_SUBREDDIT=kubernetes
NEWSLETTER_LISTING=top
NEWSLETTER_TIME_RANGE=week
NEWSLETTER_MAX_FEED_ITEMS=5
NEWSLETTER_MAX_REDDIT_POSTS=10
NEWSLETTER_MIN_SCORE=10
NEWSLETTER_MIN_COMMENTS=2
NEWSLETTER_STORAGE_PATH=./data/newsletters
# Optimization frequency (hours between optimizations)
# Default: 2.5 hours (~10 posts/day, ~€8/month budget)
OPTIMIZATION_INTERVAL_HOURS=2.5
+1 -1
View File
@@ -61,7 +61,7 @@ dev-setup:
fi
@echo "Development environment ready!"
@echo "Next steps:"
@echo " 1. Edit .env with your WordPress and Claude credentials"
@echo " 1. Edit .env with your WordPress and LLM credentials"
@echo " 2. Run 'make tidy' to download dependencies"
@echo " 3. Run 'make run' to start the application"
+158 -11
View File
@@ -1,16 +1,17 @@
# SEO Optimizer for WordPress
Production-ready Go application that automatically optimizes WordPress blog posts for SEO using Claude AI.
Production-ready Go application that automatically optimizes WordPress blog posts for SEO and generates new post ideas from Reddit using Claude or DeepSeek.
## Features
- **Autonomous Optimization**: Automatically selects and optimizes old blog posts
- **Budget-Conscious**: Single comprehensive Claude API call per post (~€0.05/post)
- **Budget-Conscious**: Single comprehensive LLM API call per post
- **Multi-Language Support**: Alternates between English and Spanish (Polylang)
- **Comprehensive SEO**: Meta tags, content improvements, internal linking, FAQ blocks
- **Draft Revisions**: Creates draft revisions for human approval (never auto-publishes)
- **Smart Selection**: Respects cooldown periods and content quality criteria
- **REST API**: Manual triggers and status monitoring
- **Idea Generation**: Daily SEO-focused post ideas from r/kubernetes
- **Production-Ready**: Docker, Kubernetes, graceful shutdown, structured logging
## Architecture
@@ -28,8 +29,8 @@ Production-ready Go application that automatically optimizes WordPress blog post
│ Cooldown: 90 days
v
┌─────────────────┐
Claude AI │ Single comprehensive prompt
(Sonnet 4.5) │ - SEO meta optimization
LLM Provider │ Single comprehensive prompt
(Claude/DeepSeek) │ - SEO meta optimization
└────────┬────────┘ - Content improvements
│ - Internal links (3-8)
│ - FAQ blocks
@@ -74,7 +75,7 @@ Production-ready Go application that automatically optimizes WordPress blog post
Required:
- `WP_APP_PASSWORD`: Generate in WordPress → Users → Profile → Application Passwords
- `CLAUDE_API_KEY`: Get from https://console.anthropic.com/
- `CLAUDE_API_KEY` (when using Claude) or `DEEPSEEK_API_KEY` (when using DeepSeek)
3. **Install dependencies**:
```bash
@@ -93,7 +94,43 @@ Production-ready Go application that automatically optimizes WordPress blog post
| Variable | Description | Default |
|----------|-------------|---------|
| `WP_APP_PASSWORD` | WordPress Application Password | **Required** |
| `CLAUDE_API_KEY` | Claude API key | **Required** |
| `LLM_PROVIDER` | LLM provider (`claude` or `deepseek`) | claude |
| `CLAUDE_API_KEY` | Claude API key (when `LLM_PROVIDER=claude`) | - |
| `DEEPSEEK_API_KEY` | DeepSeek API key (when `LLM_PROVIDER=deepseek`) | - |
| `DEEPSEEK_BASE_URL` | DeepSeek API base URL | https://api.deepseek.com |
| `DRY_RUN` | Skip WordPress draft creation/publishing | false |
| `IDEAS_ENABLED` | Enable idea generation | true |
| `IDEAS_INTERVAL_HOURS` | Hours between idea runs | 24 |
| `IDEAS_SUBREDDIT` | Subreddit to analyze | kubernetes |
| `IDEAS_LISTING` | Listing (`top`, `hot`, `new`) | top |
| `IDEAS_TIME_RANGE` | Time range for top | day |
| `IDEAS_MAX_REDDIT_POSTS` | Max Reddit posts per run | 20 |
| `IDEAS_MAX_IDEAS` | Max ideas per run | 5 |
| `IDEAS_STYLE_SAMPLES` | Recent posts used for style | 3 |
| `IDEAS_MIN_SCORE` | Min Reddit score to include | 25 |
| `IDEAS_MIN_COMMENTS` | Min Reddit comments to include | 5 |
| `IDEAS_STORAGE_PATH` | Idea storage path | ./data/ideas |
| `OUTREACH_ENABLED` | Enable outreach suggestions | true |
| `OUTREACH_SUBREDDIT` | Subreddit for outreach | kubernetes |
| `OUTREACH_LISTING` | Listing (`top`, `hot`, `new`) | top |
| `OUTREACH_TIME_RANGE` | Time range for top | day |
| `OUTREACH_MAX_REDDIT_POSTS` | Max Reddit posts per run | 20 |
| `OUTREACH_MAX_SUGGESTIONS` | Max suggestions per run | 5 |
| `OUTREACH_MIN_SCORE` | Min Reddit score to include | 25 |
| `OUTREACH_MIN_COMMENTS` | Min Reddit comments to include | 5 |
| `OUTREACH_MAX_ARTICLES` | Max internal articles to consider | 50 |
| `OUTREACH_STORAGE_PATH` | Outreach storage path | ./data/outreach |
| `NEWSLETTER_ENABLED` | Enable weekly newsletters | true |
| `NEWSLETTER_INTERVAL_HOURS` | Hours between runs | 168 |
| `NEWSLETTER_FEED_URL` | RSS feed URL | https://alexandre-vazquez.com/feed/ |
| `NEWSLETTER_SUBREDDIT` | Subreddit for context | kubernetes |
| `NEWSLETTER_LISTING` | Listing (`top`, `hot`, `new`) | top |
| `NEWSLETTER_TIME_RANGE` | Time range for top | week |
| `NEWSLETTER_MAX_FEED_ITEMS` | Max feed items | 5 |
| `NEWSLETTER_MAX_REDDIT_POSTS` | Max Reddit posts | 10 |
| `NEWSLETTER_MIN_SCORE` | Min Reddit score | 10 |
| `NEWSLETTER_MIN_COMMENTS` | Min Reddit comments | 2 |
| `NEWSLETTER_STORAGE_PATH` | Newsletter storage path | ./data/newsletters |
| `OPTIMIZATION_INTERVAL_HOURS` | Hours between optimizations | 2.5 |
| `LOG_LEVEL` | Logging level (INFO/DEBUG) | INFO |
| `API_PORT` | REST API port | 8080 |
@@ -108,11 +145,61 @@ wordpress:
base_url: "https://your-blog.com"
username: "admin"
runtime:
dry_run: false
claude:
model: "claude-sonnet-4-20250514"
max_tokens: 4096
temperature: 0.3
llm:
provider: "claude"
deepseek:
base_url: "https://api.deepseek.com"
model: "deepseek-chat"
max_tokens: 4096
temperature: 0.3
ideas:
enabled: true
interval_hours: 24
subreddit: "kubernetes"
listing: "top"
time_range: "day"
max_reddit_posts: 20
max_ideas: 5
style_sample_posts: 3
min_score: 25
min_comments: 5
base_path: "./data/ideas"
outreach:
enabled: true
subreddit: "kubernetes"
listing: "top"
time_range: "day"
max_reddit_posts: 20
max_suggestions: 5
min_score: 25
min_comments: 5
max_articles: 50
base_path: "./data/outreach"
newsletter:
enabled: true
interval_hours: 168
feed_url: "https://alexandre-vazquez.com/feed/"
subreddit: "kubernetes"
listing: "top"
time_range: "week"
max_feed_items: 5
max_reddit_posts: 10
min_score: 10
min_comments: 2
base_path: "./data/newsletters"
scheduler:
enabled: true
interval_hours: 2.5
@@ -122,10 +209,71 @@ optimization:
min_content_length: 500
min_age_days: 30
max_age_days: 365
reoptimization_cooldown_days: 365
reoptimization_cooldown_days: 180
storage:
base_path: "./data/optimizations"
audit:
base_path: "./data/audit"
```
## CLI
```bash
# List new ideas
wp-sk-cli ideas
# Generate ideas now
wp-sk-cli ideas generate
# Turn an idea into EN+ES drafts
wp-sk-cli ideas draft <idea_id>
# Publish both drafts
wp-sk-cli ideas publish <idea_id>
# Delete an idea
wp-sk-cli ideas delete <idea_id>
# List outreach suggestions
wp-sk-cli outreach
# Generate outreach suggestions now
wp-sk-cli outreach generate
# Delete an outreach suggestion
wp-sk-cli outreach delete <id>
# List newsletter drafts
wp-sk-cli newsletter
# Generate newsletter now
wp-sk-cli newsletter generate
# Delete a newsletter draft
wp-sk-cli newsletter delete <id>
# Render a draft in readable text
wp-sk-cli pending text <draft_post_id>
# Show recent audit records
wp-sk-cli audit 100
# Interactive shell
wp-sk-cli shell
# Show CLI + server version
wp-sk-cli version
wp-sk-cli --version
```
## Web UI
The server now hosts a minimal Web UI at:
```
http://localhost:8080/ui/
```
## Docker Deployment
@@ -221,7 +369,7 @@ curl -X POST http://localhost:8080/api/v1/apply-draft \
Optimizations are stored on disk for audit and review:
- Base path: `storage.base_path` (default `./data/optimizations`)
- Subfolders: `pending/`, `applied/`, `rejected/`, `skipped/`
- Files: `{post_id}_{unix_timestamp}.json` containing the full Claude response
- Files: `{post_id}_{unix_timestamp}.json` containing the full LLM response
## How It Works
@@ -245,8 +393,7 @@ The optimizer selects posts based on:
### Cost Tracking
- Model: Claude Sonnet 4.5
- Pricing: $3/1M input tokens, $15/1M output tokens
- Example (Claude): Sonnet 4.5 at $3/1M input tokens and $15/1M output tokens
- Est. per post: 8K input + 2K output ≈ $0.054 (€0.05)
- Budget: €10/month ≈ 200 posts
- Interval: 2.5 hours ≈ 10 posts/day ≈ 300 posts/month
@@ -337,7 +484,7 @@ make all
2. Check WordPress REST API is accessible: `curl https://your-blog.com/wp-json/wp/v2/posts`
3. Ensure username and password are properly set in .env
### Claude API Errors
### Claude API Errors (Claude Only)
**Problem**: "Claude API error: status 401"
+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")
}
+126 -2
View File
@@ -15,6 +15,10 @@ wordpress:
# API request timeout
timeout: 30s
runtime:
# When true, do not create or publish WordPress drafts
dry_run: false
claude:
# Claude API key (set via environment variable CLAUDE_API_KEY)
# Get from: https://console.anthropic.com/
@@ -30,6 +34,29 @@ claude:
# Temperature (0.0-1.0, lower = more consistent)
temperature: 0.3
llm:
# LLM provider to use: "claude" or "deepseek"
provider: "claude"
deepseek:
# DeepSeek API key (set via environment variable DEEPSEEK_API_KEY)
# Get from: https://platform.deepseek.com/
api_key: "${DEEPSEEK_API_KEY}"
# API base URL (default: https://api.deepseek.com)
# Use https://api.deepseek.com/v1 if you prefer explicit versioning
base_url: "https://api.deepseek.com"
# Model to use for optimizations
# deepseek-chat: general-purpose chat model
model: "deepseek-chat"
# Maximum tokens to generate
max_tokens: 4096
# Temperature (0.0-1.0, lower = more consistent)
temperature: 0.3
scheduler:
# Enable/disable automatic scheduling
enabled: true
@@ -58,9 +85,9 @@ optimization:
# Focuses on content within a reasonable timeframe
max_age_days: 365
# Don't re-optimize posts for this many days (12 months cooldown)
# Don't re-optimize posts for this many days (6 months cooldown)
# Prevents too-frequent optimization of same posts
reoptimization_cooldown_days: 365
reoptimization_cooldown_days: 180
# Maximum internal links to add per post
max_internal_links: 8
@@ -72,11 +99,108 @@ optimization:
# When true, optimize/apply translations alongside the requested language
sync_translations: true
ideas:
# Enable daily SEO idea generation from Reddit
enabled: true
# Hours between idea generation runs (default: 24)
interval_hours: 24
# Subreddit to analyze
subreddit: "kubernetes"
# Listing to use: top | hot | new
listing: "top"
# Time range for top listing: hour | day | week | month | year | all
time_range: "day"
# Max Reddit posts to analyze per run
max_reddit_posts: 20
# Max ideas to generate per run
max_ideas: 5
# Recent posts to use as style references
style_sample_posts: 3
# Filter Reddit posts by score/comment thresholds
min_score: 25
min_comments: 5
# Directory to store idea records
base_path: "./data/ideas"
outreach:
# Enable outreach suggestions from Reddit
enabled: true
# Subreddit to analyze
subreddit: "kubernetes"
# Listing to use: top | hot | new
listing: "top"
# Time range for top listing: hour | day | week | month | year | all
time_range: "day"
# Max Reddit posts to analyze per run
max_reddit_posts: 20
# Max outreach suggestions per run
max_suggestions: 5
# Filter Reddit posts by score/comment thresholds
min_score: 25
min_comments: 5
# Max internal articles to consider
max_articles: 50
# Directory to store outreach suggestions
base_path: "./data/outreach"
newsletter:
# Enable weekly newsletter drafts
enabled: true
# Hours between newsletter runs (default: 168 = weekly)
interval_hours: 168
# RSS feed URL to pull new posts from
feed_url: "https://alexandre-vazquez.com/feed/"
# Subreddit to use for community context
subreddit: "kubernetes"
# Listing to use: top | hot | new
listing: "top"
# Time range for top listing: hour | day | week | month | year | all
time_range: "week"
# Max feed items to include
max_feed_items: 5
# Max Reddit posts to include
max_reddit_posts: 10
# Filter Reddit posts by score/comment thresholds
min_score: 10
min_comments: 2
# Directory to store newsletter drafts
base_path: "./data/newsletters"
storage:
# Directory to store optimization records
# This stores pending drafts and optimization history
base_path: "./data/optimizations"
audit:
# Directory to store audit records
base_path: "./data/audit"
api:
# Enable/disable REST API server
enabled: true
+118
View File
@@ -28,13 +28,86 @@ spec:
secretKeyRef:
name: seo-optimizer-secrets
key: wp-app-password
- name: LLM_PROVIDER
value: "claude"
- name: CLAUDE_API_KEY
valueFrom:
secretKeyRef:
name: seo-optimizer-secrets
key: claude-api-key
- name: DEEPSEEK_API_KEY
valueFrom:
secretKeyRef:
name: seo-optimizer-secrets
key: deepseek-api-key
- name: OPTIMIZATION_INTERVAL_HOURS
value: "2.5"
- name: IDEAS_ENABLED
value: "true"
- name: IDEAS_INTERVAL_HOURS
value: "24"
- name: IDEAS_SUBREDDIT
value: "kubernetes"
- name: IDEAS_LISTING
value: "top"
- name: IDEAS_TIME_RANGE
value: "day"
- name: IDEAS_MAX_REDDIT_POSTS
value: "20"
- name: IDEAS_MAX_IDEAS
value: "5"
- name: IDEAS_STYLE_SAMPLES
value: "3"
- name: IDEAS_MIN_SCORE
value: "25"
- name: IDEAS_MIN_COMMENTS
value: "5"
- name: IDEAS_STORAGE_PATH
value: "/app/data/ideas"
- name: OUTREACH_ENABLED
value: "true"
- name: OUTREACH_SUBREDDIT
value: "kubernetes"
- name: OUTREACH_LISTING
value: "top"
- name: OUTREACH_TIME_RANGE
value: "day"
- name: OUTREACH_MAX_REDDIT_POSTS
value: "20"
- name: OUTREACH_MAX_SUGGESTIONS
value: "5"
- name: OUTREACH_MIN_SCORE
value: "25"
- name: OUTREACH_MIN_COMMENTS
value: "5"
- name: OUTREACH_MAX_ARTICLES
value: "50"
- name: OUTREACH_STORAGE_PATH
value: "/app/data/outreach"
- name: NEWSLETTER_ENABLED
value: "true"
- name: NEWSLETTER_INTERVAL_HOURS
value: "168"
- name: NEWSLETTER_FEED_URL
value: "https://alexandre-vazquez.com/feed/"
- name: NEWSLETTER_SUBREDDIT
value: "kubernetes"
- name: NEWSLETTER_LISTING
value: "top"
- name: NEWSLETTER_TIME_RANGE
value: "week"
- name: NEWSLETTER_MAX_FEED_ITEMS
value: "5"
- name: NEWSLETTER_MAX_REDDIT_POSTS
value: "10"
- name: NEWSLETTER_MIN_SCORE
value: "10"
- name: NEWSLETTER_MIN_COMMENTS
value: "2"
- name: NEWSLETTER_STORAGE_PATH
value: "/app/data/newsletters"
- name: DRY_RUN
value: "false"
- name: LOG_LEVEL
value: "INFO"
- name: API_PORT
@@ -92,10 +165,19 @@ data:
base_url: "https://alexandre-vazquez.com"
username: "admin"
timeout: 30s
runtime:
dry_run: false
llm:
provider: "claude"
claude:
model: "claude-sonnet-4-20250514"
max_tokens: 4096
temperature: 0.3
deepseek:
base_url: "https://api.deepseek.com"
model: "deepseek-chat"
max_tokens: 4096
temperature: 0.3
scheduler:
enabled: true
interval_hours: 2.5
@@ -107,6 +189,41 @@ data:
max_internal_links: 8
faq_min_questions: 4
faq_max_questions: 6
ideas:
enabled: true
interval_hours: 24
subreddit: "kubernetes"
listing: "top"
time_range: "day"
max_reddit_posts: 20
max_ideas: 5
style_sample_posts: 3
min_score: 25
min_comments: 5
base_path: "/app/data/ideas"
outreach:
enabled: true
subreddit: "kubernetes"
listing: "top"
time_range: "day"
max_reddit_posts: 20
max_suggestions: 5
min_score: 25
min_comments: 5
max_articles: 50
base_path: "/app/data/outreach"
newsletter:
enabled: true
interval_hours: 168
feed_url: "https://alexandre-vazquez.com/feed/"
subreddit: "kubernetes"
listing: "top"
time_range: "week"
max_feed_items: 5
max_reddit_posts: 10
min_score: 10
min_comments: 2
base_path: "/app/data/newsletters"
api:
enabled: true
port: 8080
@@ -123,3 +240,4 @@ type: Opaque
stringData:
wp-app-password: "REPLACE_WITH_YOUR_WORDPRESS_APP_PASSWORD"
claude-api-key: "REPLACE_WITH_YOUR_CLAUDE_API_KEY"
deepseek-api-key: "REPLACE_WITH_YOUR_DEEPSEEK_API_KEY"
+49 -1
View File
@@ -13,12 +13,60 @@ services:
# WordPress credentials
- WP_APP_PASSWORD=${WP_APP_PASSWORD}
# LLM provider (claude or deepseek)
- LLM_PROVIDER=${LLM_PROVIDER:-claude}
# Claude API key
- CLAUDE_API_KEY=${CLAUDE_API_KEY}
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
# DeepSeek API key + base URL
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- DEEPSEEK_BASE_URL=${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
# Optimization configuration
- OPTIMIZATION_INTERVAL_HOURS=${OPTIMIZATION_INTERVAL_HOURS:-2.5}
# Idea generation configuration
- IDEAS_ENABLED=${IDEAS_ENABLED:-true}
- IDEAS_INTERVAL_HOURS=${IDEAS_INTERVAL_HOURS:-24}
- IDEAS_SUBREDDIT=${IDEAS_SUBREDDIT:-kubernetes}
- IDEAS_LISTING=${IDEAS_LISTING:-top}
- IDEAS_TIME_RANGE=${IDEAS_TIME_RANGE:-day}
- IDEAS_MAX_REDDIT_POSTS=${IDEAS_MAX_REDDIT_POSTS:-20}
- IDEAS_MAX_IDEAS=${IDEAS_MAX_IDEAS:-5}
- IDEAS_STYLE_SAMPLES=${IDEAS_STYLE_SAMPLES:-3}
- IDEAS_MIN_SCORE=${IDEAS_MIN_SCORE:-25}
- IDEAS_MIN_COMMENTS=${IDEAS_MIN_COMMENTS:-5}
- IDEAS_STORAGE_PATH=${IDEAS_STORAGE_PATH:-/app/data/ideas}
# Outreach configuration
- OUTREACH_ENABLED=${OUTREACH_ENABLED:-true}
- OUTREACH_SUBREDDIT=${OUTREACH_SUBREDDIT:-kubernetes}
- OUTREACH_LISTING=${OUTREACH_LISTING:-top}
- OUTREACH_TIME_RANGE=${OUTREACH_TIME_RANGE:-day}
- OUTREACH_MAX_REDDIT_POSTS=${OUTREACH_MAX_REDDIT_POSTS:-20}
- OUTREACH_MAX_SUGGESTIONS=${OUTREACH_MAX_SUGGESTIONS:-5}
- OUTREACH_MIN_SCORE=${OUTREACH_MIN_SCORE:-25}
- OUTREACH_MIN_COMMENTS=${OUTREACH_MIN_COMMENTS:-5}
- OUTREACH_MAX_ARTICLES=${OUTREACH_MAX_ARTICLES:-50}
- OUTREACH_STORAGE_PATH=${OUTREACH_STORAGE_PATH:-/app/data/outreach}
# Newsletter configuration
- NEWSLETTER_ENABLED=${NEWSLETTER_ENABLED:-true}
- NEWSLETTER_INTERVAL_HOURS=${NEWSLETTER_INTERVAL_HOURS:-168}
- NEWSLETTER_FEED_URL=${NEWSLETTER_FEED_URL:-https://alexandre-vazquez.com/feed/}
- NEWSLETTER_SUBREDDIT=${NEWSLETTER_SUBREDDIT:-kubernetes}
- NEWSLETTER_LISTING=${NEWSLETTER_LISTING:-top}
- NEWSLETTER_TIME_RANGE=${NEWSLETTER_TIME_RANGE:-week}
- NEWSLETTER_MAX_FEED_ITEMS=${NEWSLETTER_MAX_FEED_ITEMS:-5}
- NEWSLETTER_MAX_REDDIT_POSTS=${NEWSLETTER_MAX_REDDIT_POSTS:-10}
- NEWSLETTER_MIN_SCORE=${NEWSLETTER_MIN_SCORE:-10}
- NEWSLETTER_MIN_COMMENTS=${NEWSLETTER_MIN_COMMENTS:-2}
- NEWSLETTER_STORAGE_PATH=${NEWSLETTER_STORAGE_PATH:-/app/data/newsletters}
# Dry-run mode
- DRY_RUN=${DRY_RUN:-false}
# Logging
- LOG_LEVEL=${LOG_LEVEL:-INFO}
+178
View File
@@ -214,6 +214,151 @@ Or use the helper script:
---
### 7. Get Draft Content
Fetch draft content for CLI-friendly review.
```bash
GET /drafts/{draft_post_id}
```
**Response:**
```json
{
"id": 456,
"status": "draft",
"title": "Draft title",
"excerpt": "Draft excerpt",
"content": "<p>Rendered HTML...</p>"
}
```
---
### 8. Ideas: List
List stored idea records by status.
```bash
GET /ideas?status=new
```
**Response:**
```json
{
"count": 3,
"ideas": [
{
"id": "uuid",
"seo_title": "Kubernetes Pod Security Standards: What Changed in 2025",
"summary": "Idea summary...",
"status": "new",
"created_at": "2026-01-25T09:00:00Z"
}
]
}
```
---
### 9. Ideas: Generate Now
Trigger idea generation from Reddit immediately.
```bash
POST /ideas/generate
```
---
### 10. Ideas: Generate Drafts
Generate full English + Spanish drafts for an idea.
```bash
POST /ideas/{id}/draft
```
---
### 11. Ideas: Publish Drafts
Publish the drafts created for an idea.
```bash
POST /ideas/{id}/publish
```
---
### 12. Ideas: Delete
Delete an idea record (and any drafts created from it).
```bash
POST /ideas/{id}/delete
```
---
### 13. Outreach: List
List outreach suggestions.
```bash
GET /outreach?status=new
```
---
### 14. Outreach: Generate Now
Generate outreach suggestions from Reddit now.
```bash
POST /outreach/generate
```
---
### 15. Outreach: Delete
Delete an outreach suggestion.
```bash
POST /outreach/{id}/delete
```
---
### 16. Newsletter: List
List newsletter drafts.
```bash
GET /newsletter?status=draft
```
---
### 17. Newsletter: Generate Now
Generate a newsletter draft from RSS + Reddit.
```bash
POST /newsletter/generate
```
---
### 18. Newsletter: Delete
Delete a newsletter draft.
```bash
POST /newsletter/{id}/delete
```
## Workflow
### Typical Optimization Workflow
@@ -298,6 +443,36 @@ Each file contains the complete optimization record including:
---
## Audit Log
List recent actions recorded by the system (CLI/WebUI/API).
```bash
GET /audit?limit=100
```
**Response:**
```json
{
"count": 2,
"records": [
{
"id": "uuid-string",
"created_at": "2025-01-24T10:05:00Z",
"actor": "cli",
"action": "draft_applied",
"summary": "Applied draft 456 to original post 123",
"post_id": 123,
"draft_post_id": 456
}
]
}
```
Audit records are stored at the configured `audit.base_path`.
---
## Configuration
Enable/disable the API server in `config.yaml`:
@@ -309,6 +484,9 @@ api:
storage:
base_path: "./data/optimizations"
audit:
base_path: "./data/audit"
```
---
+167 -57
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
"seo-optimizer/internal/logger"
@@ -144,7 +143,7 @@ func (c *ClaudeClient) OptimizePost(
responseText := claudeResp.Content[0].Text
// Parse JSON response
optimization, err := c.parseOptimizationResponse(responseText)
optimization, err := parseOptimizationResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse Claude response",
"error", err,
@@ -173,6 +172,172 @@ func (c *ClaudeClient) OptimizePost(
return optimization, nil
}
// GeneratePostIdeas requests SEO-focused post ideas from Claude.
func (c *ClaudeClient) GeneratePostIdeas(ctx context.Context, prompt string) ([]*models.PostIdea, error) {
req := ClaudeRequest{
Model: c.model,
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Messages: []ClaudeMessage{
{Role: "user", Content: prompt},
},
}
startTime := time.Now()
claudeResp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("claude API call failed: %w", err)
}
duration := time.Since(startTime)
if len(claudeResp.Content) == 0 {
return nil, fmt.Errorf("claude response has no content")
}
responseText := claudeResp.Content[0].Text
ideas, err := parseIdeasResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse Claude ideas response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse Claude ideas response: %w", err)
}
c.logger.Info("Claude ideas generated",
"count", len(ideas),
"duration", duration,
"input_tokens", claudeResp.Usage.InputTokens,
"output_tokens", claudeResp.Usage.OutputTokens,
)
return ideas, nil
}
// GenerateArticleDraft requests a full article draft from Claude.
func (c *ClaudeClient) GenerateArticleDraft(ctx context.Context, prompt string) (*models.ArticleDraft, error) {
req := ClaudeRequest{
Model: c.model,
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Messages: []ClaudeMessage{
{Role: "user", Content: prompt},
},
}
startTime := time.Now()
claudeResp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("claude API call failed: %w", err)
}
duration := time.Since(startTime)
if len(claudeResp.Content) == 0 {
return nil, fmt.Errorf("claude response has no content")
}
responseText := claudeResp.Content[0].Text
draft, err := parseArticleDraftResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse Claude article response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse Claude article response: %w", err)
}
c.logger.Info("Claude article generated",
"duration", duration,
"input_tokens", claudeResp.Usage.InputTokens,
"output_tokens", claudeResp.Usage.OutputTokens,
)
return draft, nil
}
// GenerateOutreachSuggestions requests outreach suggestions from Claude.
func (c *ClaudeClient) GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error) {
req := ClaudeRequest{
Model: c.model,
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Messages: []ClaudeMessage{
{Role: "user", Content: prompt},
},
}
startTime := time.Now()
claudeResp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("claude API call failed: %w", err)
}
duration := time.Since(startTime)
if len(claudeResp.Content) == 0 {
return nil, fmt.Errorf("claude response has no content")
}
responseText := claudeResp.Content[0].Text
suggestions, err := parseOutreachResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse Claude outreach response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse Claude outreach response: %w", err)
}
c.logger.Info("Claude outreach suggestions generated",
"count", len(suggestions),
"duration", duration,
"input_tokens", claudeResp.Usage.InputTokens,
"output_tokens", claudeResp.Usage.OutputTokens,
)
return suggestions, nil
}
// GenerateNewsletterDraft requests a newsletter draft from Claude.
func (c *ClaudeClient) GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error) {
req := ClaudeRequest{
Model: c.model,
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Messages: []ClaudeMessage{
{Role: "user", Content: prompt},
},
}
startTime := time.Now()
claudeResp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("claude API call failed: %w", err)
}
duration := time.Since(startTime)
if len(claudeResp.Content) == 0 {
return nil, fmt.Errorf("claude response has no content")
}
responseText := claudeResp.Content[0].Text
draft, err := parseNewsletterResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse Claude newsletter response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse Claude newsletter response: %w", err)
}
c.logger.Info("Claude newsletter draft generated",
"duration", duration,
"input_tokens", claudeResp.Usage.InputTokens,
"output_tokens", claudeResp.Usage.OutputTokens,
)
return draft, nil
}
// callAPI performs the actual HTTP request to the Claude API
func (c *ClaudeClient) callAPI(ctx context.Context, req ClaudeRequest) (*ClaudeResponse, error) {
// Marshal request payload
@@ -230,61 +395,6 @@ func (c *ClaudeClient) callAPI(ctx context.Context, req ClaudeRequest) (*ClaudeR
return &claudeResp, nil
}
// parseOptimizationResponse parses the JSON response from Claude
func (c *ClaudeClient) parseOptimizationResponse(text string) (*models.OptimizationResponse, error) {
// Clean up the response text
// Claude might return with markdown code blocks, strip them
text = strings.TrimSpace(text)
// Remove markdown code blocks if present
if strings.HasPrefix(text, "```json") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
} else if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
// Find the JSON object (starts with { and ends with })
startIdx := strings.Index(text, "{")
endIdx := strings.LastIndex(text, "}")
if startIdx == -1 || endIdx == -1 || startIdx >= endIdx {
return nil, fmt.Errorf("no valid JSON found in response")
}
jsonText := text[startIdx : endIdx+1]
// Parse JSON
var optimization models.OptimizationResponse
if err := json.Unmarshal([]byte(jsonText), &optimization); err != nil {
return nil, fmt.Errorf("JSON unmarshal error: %w", err)
}
// Validate required fields
if optimization.SEOMeta.Title == "" {
return nil, fmt.Errorf("missing required field: seo_meta.title")
}
if optimization.SEOMeta.Description == "" {
return nil, fmt.Errorf("missing required field: seo_meta.description")
}
if optimization.Summary == "" {
return nil, fmt.Errorf("missing required field: summary")
}
c.logger.Debug("Parsed optimization response",
"seo_title_length", len(optimization.SEOMeta.Title),
"seo_desc_length", len(optimization.SEOMeta.Description),
"changes", len(optimization.ContentOptimization.Changes),
"links", len(optimization.ContentOptimization.InternalLinks),
"faq_should_create", optimization.FAQBlock.ShouldCreate,
)
return &optimization, nil
}
// calculateCost calculates the USD cost for token usage
func calculateCost(inputTokens, outputTokens int) float64 {
inputCost := (float64(inputTokens) / 1_000_000) * inputTokenPricePer1M
+357
View File
@@ -0,0 +1,357 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
const deepSeekDefaultBaseURL = "https://api.deepseek.com"
// DeepSeekClient handles communication with the DeepSeek API.
type DeepSeekClient struct {
apiKey string
baseURL string
model string
maxTokens int
temperature float64
httpClient *http.Client
logger *logger.Logger
}
type deepSeekRequest struct {
Model string `json:"model"`
Messages []deepSeekMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream"`
}
type deepSeekMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type deepSeekResponse struct {
Choices []struct {
Message deepSeekMessage `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
// NewDeepSeekClient creates a new DeepSeek API client.
func NewDeepSeekClient(apiKey, baseURL, model string, maxTokens int, temperature float64, log *logger.Logger) *DeepSeekClient {
if log == nil {
log = logger.NewDefaultLogger()
}
if strings.TrimSpace(baseURL) == "" {
baseURL = deepSeekDefaultBaseURL
}
return &DeepSeekClient{
apiKey: apiKey,
baseURL: baseURL,
model: model,
maxTokens: maxTokens,
temperature: temperature,
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
logger: log.WithComponent("deepseek"),
}
}
// OptimizePost sends a post to DeepSeek for SEO optimization and returns the structured response.
func (c *DeepSeekClient) OptimizePost(
ctx context.Context,
post *wordpress.Post,
internalPosts []*wordpress.InternalPostReference,
categories map[int]string,
tags map[int]string,
) (*models.OptimizationResponse, error) {
c.logger.Info("Optimizing post with DeepSeek",
"post_id", post.ID,
"title", post.Title.Rendered,
"language", post.Lang,
)
promptData := BuildPromptData(post, internalPosts, categories, tags)
prompt, err := RenderPrompt(promptData)
if err != nil {
return nil, fmt.Errorf("failed to render prompt: %w", err)
}
c.logger.Debug("Prompt rendered",
"length", len(prompt),
"word_count", promptData.WordCount,
)
req := deepSeekRequest{
Model: c.model,
Messages: []deepSeekMessage{{Role: "user", Content: prompt}},
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: false,
}
startTime := time.Now()
resp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("deepseek API call failed: %w", err)
}
duration := time.Since(startTime)
if len(resp.Choices) == 0 || strings.TrimSpace(resp.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("deepseek response has no content")
}
responseText := resp.Choices[0].Message.Content
optimization, err := parseOptimizationResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse DeepSeek response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse DeepSeek response: %w", err)
}
c.logger.Info("DeepSeek optimization completed",
"post_id", post.ID,
"duration", duration,
"input_tokens", resp.Usage.PromptTokens,
"output_tokens", resp.Usage.CompletionTokens,
"changes", len(optimization.ContentOptimization.Changes),
"internal_links", len(optimization.ContentOptimization.InternalLinks),
"faq_questions", len(optimization.FAQBlock.Questions),
"fact_checks", len(optimization.Validation.FactChecks),
"broken_links", len(optimization.Validation.BrokenLinks),
)
return optimization, nil
}
// GeneratePostIdeas requests SEO-focused post ideas from DeepSeek.
func (c *DeepSeekClient) GeneratePostIdeas(ctx context.Context, prompt string) ([]*models.PostIdea, error) {
req := deepSeekRequest{
Model: c.model,
Messages: []deepSeekMessage{{Role: "user", Content: prompt}},
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: false,
}
startTime := time.Now()
resp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("deepseek API call failed: %w", err)
}
duration := time.Since(startTime)
if len(resp.Choices) == 0 || strings.TrimSpace(resp.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("deepseek response has no content")
}
responseText := resp.Choices[0].Message.Content
ideas, err := parseIdeasResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse DeepSeek ideas response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse DeepSeek ideas response: %w", err)
}
c.logger.Info("DeepSeek ideas generated",
"count", len(ideas),
"duration", duration,
"input_tokens", resp.Usage.PromptTokens,
"output_tokens", resp.Usage.CompletionTokens,
)
return ideas, nil
}
// GenerateArticleDraft requests a full article draft from DeepSeek.
func (c *DeepSeekClient) GenerateArticleDraft(ctx context.Context, prompt string) (*models.ArticleDraft, error) {
req := deepSeekRequest{
Model: c.model,
Messages: []deepSeekMessage{{Role: "user", Content: prompt}},
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: false,
}
startTime := time.Now()
resp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("deepseek API call failed: %w", err)
}
duration := time.Since(startTime)
if len(resp.Choices) == 0 || strings.TrimSpace(resp.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("deepseek response has no content")
}
responseText := resp.Choices[0].Message.Content
draft, err := parseArticleDraftResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse DeepSeek article response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse DeepSeek article response: %w", err)
}
c.logger.Info("DeepSeek article generated",
"duration", duration,
"input_tokens", resp.Usage.PromptTokens,
"output_tokens", resp.Usage.CompletionTokens,
)
return draft, nil
}
// GenerateOutreachSuggestions requests outreach suggestions from DeepSeek.
func (c *DeepSeekClient) GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error) {
req := deepSeekRequest{
Model: c.model,
Messages: []deepSeekMessage{{Role: "user", Content: prompt}},
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: false,
}
startTime := time.Now()
resp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("deepseek API call failed: %w", err)
}
duration := time.Since(startTime)
if len(resp.Choices) == 0 || strings.TrimSpace(resp.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("deepseek response has no content")
}
responseText := resp.Choices[0].Message.Content
suggestions, err := parseOutreachResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse DeepSeek outreach response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse DeepSeek outreach response: %w", err)
}
c.logger.Info("DeepSeek outreach suggestions generated",
"count", len(suggestions),
"duration", duration,
"input_tokens", resp.Usage.PromptTokens,
"output_tokens", resp.Usage.CompletionTokens,
)
return suggestions, nil
}
// GenerateNewsletterDraft requests a newsletter draft from DeepSeek.
func (c *DeepSeekClient) GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error) {
req := deepSeekRequest{
Model: c.model,
Messages: []deepSeekMessage{{Role: "user", Content: prompt}},
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: false,
}
startTime := time.Now()
resp, err := c.callAPI(ctx, req)
if err != nil {
return nil, fmt.Errorf("deepseek API call failed: %w", err)
}
duration := time.Since(startTime)
if len(resp.Choices) == 0 || strings.TrimSpace(resp.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("deepseek response has no content")
}
responseText := resp.Choices[0].Message.Content
draft, err := parseNewsletterResponse(responseText, c.logger)
if err != nil {
c.logger.Error("Failed to parse DeepSeek newsletter response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse DeepSeek newsletter response: %w", err)
}
c.logger.Info("DeepSeek newsletter draft generated",
"duration", duration,
"input_tokens", resp.Usage.PromptTokens,
"output_tokens", resp.Usage.CompletionTokens,
)
return draft, nil
}
func (c *DeepSeekClient) callAPI(ctx context.Context, req deepSeekRequest) (*deepSeekResponse, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
url := strings.TrimRight(c.baseURL, "/") + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("content-type", "application/json")
c.logger.Debug("Sending request to DeepSeek API",
"model", req.Model,
"max_tokens", req.MaxTokens,
"temperature", req.Temperature,
"payload_size", len(payload),
"base_url", c.baseURL,
)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
c.logger.Error("DeepSeek API error",
"status", resp.StatusCode,
"body", string(body),
)
return nil, fmt.Errorf("DeepSeek API error (status %d): %s", resp.StatusCode, string(body))
}
var deepSeekResp deepSeekResponse
if err := json.Unmarshal(body, &deepSeekResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal DeepSeek response: %w", err)
}
return &deepSeekResp, nil
}
+24
View File
@@ -0,0 +1,24 @@
package agent
import (
"context"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
// LLMClient defines the contract for supported LLM providers.
type LLMClient interface {
OptimizePost(
ctx context.Context,
post *wordpress.Post,
internalPosts []*wordpress.InternalPostReference,
categories map[int]string,
tags map[int]string,
) (*models.OptimizationResponse, error)
GeneratePostIdeas(ctx context.Context, prompt string) ([]*models.PostIdea, error)
GenerateArticleDraft(ctx context.Context, prompt string) (*models.ArticleDraft, error)
GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error)
GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error)
}
+4 -1
View File
@@ -34,7 +34,7 @@ type InternalPostRef struct {
Categories string
}
// SEOOptimizationPromptTemplate is the comprehensive prompt sent to Claude
// 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.
**Task**: Analyze the provided blog post and generate comprehensive SEO optimizations.
@@ -165,6 +165,7 @@ Respond with ONLY valid JSON (no markdown code blocks, no explanations outside J
4. **FAQ Block (RankMath)** (4-6 questions):
- Create FAQ only if content benefits from structured Q&A
- If Language is Spanish, the FAQ section title must be "Preguntas Frecuentes"
- Focus on questions users actually search for (use question keywords)
- Answers: 40-80 words, clear, direct, technically accurate
- Include product names, technical terms, and version info where relevant
@@ -196,6 +197,8 @@ Respond with ONLY valid JSON (no markdown code blocks, no explanations outside J
- 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)
- 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.
- Never translate code or technical terminology (objects, products, techniques). Examples: Kubernetes objects, Helm, TLS edge-termination.
- Return ONLY valid JSON, no markdown formatting, no text outside JSON
`
+228
View File
@@ -0,0 +1,228 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"seo-optimizer/internal/logger"
"seo-optimizer/pkg/models"
)
func parseOptimizationResponse(text string, log *logger.Logger) (*models.OptimizationResponse, error) {
text = strings.TrimSpace(text)
if strings.HasPrefix(text, "```json") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
} else if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
startIdx := strings.Index(text, "{")
endIdx := strings.LastIndex(text, "}")
if startIdx == -1 || endIdx == -1 || startIdx >= endIdx {
return nil, fmt.Errorf("no valid JSON found in response")
}
jsonText := text[startIdx : endIdx+1]
var optimization models.OptimizationResponse
if err := json.Unmarshal([]byte(jsonText), &optimization); err != nil {
return nil, fmt.Errorf("JSON unmarshal error: %w", err)
}
if optimization.SEOMeta.Title == "" {
return nil, fmt.Errorf("missing required field: seo_meta.title")
}
if optimization.SEOMeta.Description == "" {
return nil, fmt.Errorf("missing required field: seo_meta.description")
}
if optimization.Summary == "" {
return nil, fmt.Errorf("missing required field: summary")
}
if log != nil {
log.Debug("Parsed optimization response",
"seo_title_length", len(optimization.SEOMeta.Title),
"seo_desc_length", len(optimization.SEOMeta.Description),
"changes", len(optimization.ContentOptimization.Changes),
"links", len(optimization.ContentOptimization.InternalLinks),
"faq_should_create", optimization.FAQBlock.ShouldCreate,
)
}
return &optimization, nil
}
type ideaSeed struct {
SEOTitle string `json:"seo_title"`
Summary string `json:"summary"`
PrimaryKeyword string `json:"primary_keyword"`
Sources []models.IdeaSource `json:"sources"`
References []string `json:"references"`
}
type ideasEnvelope struct {
Ideas []ideaSeed `json:"ideas"`
}
func parseIdeasResponse(text string, log *logger.Logger) ([]*models.PostIdea, error) {
clean, err := extractJSON(text)
if err != nil {
return nil, err
}
var envelope ideasEnvelope
if err := json.Unmarshal([]byte(clean), &envelope); err != nil {
return nil, fmt.Errorf("JSON unmarshal error: %w", err)
}
if len(envelope.Ideas) == 0 {
return nil, fmt.Errorf("no ideas returned")
}
ideas := make([]*models.PostIdea, 0, len(envelope.Ideas))
for _, seed := range envelope.Ideas {
if seed.SEOTitle == "" || seed.Summary == "" {
continue
}
ideas = append(ideas, &models.PostIdea{
SEOTitle: seed.SEOTitle,
Summary: seed.Summary,
PrimaryKW: seed.PrimaryKeyword,
Sources: seed.Sources,
References: seed.References,
})
}
if len(ideas) == 0 {
return nil, fmt.Errorf("ideas missing required fields")
}
if log != nil {
log.Debug("Parsed ideas response", "count", len(ideas))
}
return ideas, nil
}
func parseArticleDraftResponse(text string, log *logger.Logger) (*models.ArticleDraft, error) {
clean, err := extractJSON(text)
if err != nil {
return nil, err
}
var draft models.ArticleDraft
if err := json.Unmarshal([]byte(clean), &draft); err != nil {
return nil, fmt.Errorf("JSON unmarshal error: %w", err)
}
if draft.SEOTitle == "" || draft.MetaDescription == "" || draft.ContentHTML == "" {
return nil, fmt.Errorf("missing required article fields")
}
if log != nil {
log.Debug("Parsed article draft", "language", draft.Language, "title_length", len(draft.SEOTitle))
}
return &draft, nil
}
func extractJSON(text string) (string, error) {
text = strings.TrimSpace(text)
if strings.HasPrefix(text, "```json") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
} else if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
startIdx := strings.Index(text, "{")
endIdx := strings.LastIndex(text, "}")
if startIdx == -1 || endIdx == -1 || startIdx >= endIdx {
return "", fmt.Errorf("no valid JSON found in response")
}
return text[startIdx : endIdx+1], nil
}
type outreachSeed struct {
RedditURL string `json:"reddit_url"`
RedditTitle string `json:"reddit_title"`
ArticleTitle string `json:"article_title"`
ArticleURL string `json:"article_url"`
SuggestedResponse string `json:"suggested_response"`
}
type outreachEnvelope struct {
Suggestions []outreachSeed `json:"suggestions"`
}
func parseOutreachResponse(text string, log *logger.Logger) ([]*models.OutreachSuggestion, error) {
clean, err := extractJSON(text)
if err != nil {
return nil, err
}
var envelope outreachEnvelope
if err := json.Unmarshal([]byte(clean), &envelope); err != nil {
return nil, fmt.Errorf("JSON unmarshal error: %w", err)
}
if len(envelope.Suggestions) == 0 {
return nil, fmt.Errorf("no suggestions returned")
}
suggestions := make([]*models.OutreachSuggestion, 0, len(envelope.Suggestions))
for _, seed := range envelope.Suggestions {
if seed.RedditURL == "" || seed.ArticleURL == "" || seed.SuggestedResponse == "" {
continue
}
suggestions = append(suggestions, &models.OutreachSuggestion{
RedditURL: seed.RedditURL,
RedditTitle: seed.RedditTitle,
ArticleTitle: seed.ArticleTitle,
ArticleURL: seed.ArticleURL,
SuggestedResponse: seed.SuggestedResponse,
})
}
if len(suggestions) == 0 {
return nil, fmt.Errorf("suggestions missing required fields")
}
if log != nil {
log.Debug("Parsed outreach suggestions", "count", len(suggestions))
}
return suggestions, nil
}
func parseNewsletterResponse(text string, log *logger.Logger) (*models.NewsletterDraft, error) {
clean, err := extractJSON(text)
if err != nil {
return nil, err
}
var draft models.NewsletterDraft
if err := json.Unmarshal([]byte(clean), &draft); err != nil {
return nil, fmt.Errorf("JSON unmarshal error: %w", err)
}
if draft.Subject == "" || draft.BodyHTML == "" || draft.BodyText == "" {
return nil, fmt.Errorf("missing required newsletter fields")
}
if log != nil {
log.Debug("Parsed newsletter draft", "subject_length", len(draft.Subject))
}
return &draft, nil
}
+562 -16
View File
@@ -14,7 +14,9 @@ import (
"github.com/google/uuid"
"github.com/gorilla/mux"
"seo-optimizer/internal/ideas"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/newsletter"
"seo-optimizer/internal/seo"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
@@ -25,6 +27,10 @@ type Server struct {
optimizer *seo.Optimizer
wpClient *wordpress.Client
storage *storage.OptimizationStorage
audit *storage.AuditStorage
ideaSvc *ideas.Service
newsSvc *newsletter.Service
version string
port int
server *http.Server
logger *logger.Logger
@@ -36,18 +42,19 @@ type Server struct {
// Job represents an optimization job (running or completed)
type Job struct {
ID string `json:"id"`
PostID int `json:"post_id"`
Language string `json:"language"`
Status string `json:"status"` // "running", "completed", "failed"
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Error string `json:"error,omitempty"`
Summary string `json:"summary,omitempty"`
ID string `json:"id"`
PostID int `json:"post_id"`
Language string `json:"language"`
Status string `json:"status"` // "running", "completed", "failed"
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Error string `json:"error,omitempty"`
Summary string `json:"summary,omitempty"`
RequestedBy string `json:"requested_by,omitempty"`
}
// NewServer creates a new API server
func NewServer(optimizer *seo.Optimizer, wpClient *wordpress.Client, store *storage.OptimizationStorage, port int, log *logger.Logger) *Server {
func NewServer(optimizer *seo.Optimizer, wpClient *wordpress.Client, store *storage.OptimizationStorage, audit *storage.AuditStorage, ideaSvc *ideas.Service, newsSvc *newsletter.Service, version string, port int, log *logger.Logger) *Server {
if log == nil {
log = logger.NewDefaultLogger()
}
@@ -56,12 +63,38 @@ func NewServer(optimizer *seo.Optimizer, wpClient *wordpress.Client, store *stor
optimizer: optimizer,
wpClient: wpClient,
storage: store,
audit: audit,
ideaSvc: ideaSvc,
newsSvc: newsSvc,
version: version,
port: port,
jobs: make(map[string]*Job),
logger: log.WithComponent("api"),
}
}
func clientFromRequest(r *http.Request) string {
if r == nil {
return "system"
}
if client := strings.TrimSpace(r.Header.Get("X-WPSK-Client")); client != "" {
return strings.ToLower(client)
}
return "api"
}
func (s *Server) recordAudit(r *http.Request, record *storage.AuditRecord) {
if s.audit == nil || record == nil {
return
}
if record.Actor == "" {
record.Actor = clientFromRequest(r)
}
if err := s.audit.Save(record); err != nil {
s.logger.Warn("Failed to save audit record", "error", err)
}
}
// Start starts the HTTP server
func (s *Server) Start() error {
router := mux.NewRouter()
@@ -74,6 +107,30 @@ func (s *Server) Start() error {
router.HandleFunc("/api/v1/optimization/{post_id}", s.handleGetOptimization).Methods("GET")
router.HandleFunc("/api/v1/status/{job_id}", s.handleStatus).Methods("GET")
router.HandleFunc("/api/v1/health", s.handleHealth).Methods("GET")
router.HandleFunc("/api/v1/audit", s.handleListAudit).Methods("GET")
router.HandleFunc("/api/v1/compare/{draft_id}", s.handleGetComparison).Methods("GET")
router.HandleFunc("/api/v1/ideas", s.handleListIdeas).Methods("GET")
router.HandleFunc("/api/v1/ideas/generate", s.handleGenerateIdeas).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/draft", s.handleGenerateIdeaDraft).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/publish", s.handlePublishIdeaDraft).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/delete", s.handleDeleteIdea).Methods("POST")
router.HandleFunc("/api/v1/drafts/{id}", s.handleGetDraft).Methods("GET")
router.HandleFunc("/api/v1/outreach", s.handleListOutreach).Methods("GET")
router.HandleFunc("/api/v1/outreach/generate", s.handleGenerateOutreach).Methods("POST")
router.HandleFunc("/api/v1/outreach/{id}/delete", s.handleDeleteOutreach).Methods("POST")
router.HandleFunc("/api/v1/newsletter", s.handleListNewsletter).Methods("GET")
router.HandleFunc("/api/v1/newsletter/generate", s.handleGenerateNewsletter).Methods("POST")
router.HandleFunc("/api/v1/newsletter/{id}/delete", s.handleDeleteNewsletter).Methods("POST")
uiServer, err := uiFileServer()
if err == nil {
router.HandleFunc("/ui", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusMovedPermanently)
})
router.PathPrefix("/ui/").Handler(http.StripPrefix("/ui/", uiServer))
} else {
s.logger.Warn("Failed to initialize UI file server", "error", err)
}
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
@@ -119,11 +176,12 @@ func (s *Server) handleOptimize(w http.ResponseWriter, r *http.Request) {
// Create job
jobID := uuid.New().String()
job := &Job{
ID: jobID,
PostID: req.PostID,
Language: req.Language,
Status: "running",
StartedAt: time.Now(),
ID: jobID,
PostID: req.PostID,
Language: req.Language,
Status: "running",
StartedAt: time.Now(),
RequestedBy: clientFromRequest(r),
}
s.jobsMu.Lock()
@@ -135,6 +193,13 @@ func (s *Server) handleOptimize(w http.ResponseWriter, r *http.Request) {
"post_id", req.PostID,
"language", req.Language,
)
s.recordAudit(r, &storage.AuditRecord{
Action: "optimize_requested",
Summary: fmt.Sprintf("Optimization requested for post %d (%s)", req.PostID, req.Language),
PostID: req.PostID,
JobID: jobID,
Language: req.Language,
})
// Run optimization asynchronously
go s.runOptimizationJob(job)
@@ -185,6 +250,10 @@ func (s *Server) handleApplyDraft(w http.ResponseWriter, r *http.Request) {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
if s.optimizer != nil && s.optimizer.IsDryRun() {
http.Error(w, "dry-run enabled: apply-draft is disabled", http.StatusBadRequest)
return
}
s.logger.Info("Applying draft to original post",
"draft_post_id", req.DraftPostID,
@@ -242,6 +311,16 @@ func (s *Server) handleApplyDraft(w http.ResponseWriter, r *http.Request) {
s.logger.Info("Draft applied successfully",
"draft_post_id", req.DraftPostID,
)
summary := fmt.Sprintf("Applied draft %d", req.DraftPostID)
if originalPostID > 0 {
summary = fmt.Sprintf("Applied draft %d to original post %d", req.DraftPostID, originalPostID)
}
s.recordAudit(r, &storage.AuditRecord{
Action: "draft_applied",
Summary: summary,
PostID: originalPostID,
DraftPostID: req.DraftPostID,
})
// Return success response
w.Header().Set("Content-Type", "application/json")
@@ -371,6 +450,32 @@ func parsePostID(raw interface{}) (int, bool) {
return 0, false
}
func getMetaString(meta map[string]interface{}, keys ...string) string {
if meta == nil {
return ""
}
for _, key := range keys {
raw, ok := meta[key]
if !ok {
continue
}
switch v := raw.(type) {
case string:
return v
case []interface{}:
if len(v) == 0 {
continue
}
if s, ok := v[0].(string); ok {
return s
}
case fmt.Stringer:
return v.String()
}
}
return ""
}
func translationPostIDs(post *wordpress.Post, excludeID int) []int {
if post == nil || len(post.Translations) == 0 {
return nil
@@ -404,6 +509,10 @@ func (s *Server) handleRejectDraft(w http.ResponseWriter, r *http.Request) {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
if s.optimizer != nil && s.optimizer.IsDryRun() {
http.Error(w, "dry-run enabled: reject-draft is disabled", http.StatusBadRequest)
return
}
s.logger.Info("Rejecting draft post",
"draft_post_id", req.DraftPostID,
@@ -419,12 +528,14 @@ func (s *Server) handleRejectDraft(w http.ResponseWriter, r *http.Request) {
return
}
originalPostID := 0
pendingRecords, err := s.storage.ListPending()
if err != nil {
s.logger.Warn("Failed to list pending records", "error", err)
} else {
for _, record := range pendingRecords {
if record.DraftPostID == req.DraftPostID {
originalPostID = record.OriginalPostID
if err := s.storage.UpdateStatus(record.OriginalPostID, "rejected"); err != nil {
s.logger.Warn("Failed to update optimization record status", "error", err)
}
@@ -433,6 +544,13 @@ func (s *Server) handleRejectDraft(w http.ResponseWriter, r *http.Request) {
}
}
s.recordAudit(r, &storage.AuditRecord{
Action: "draft_rejected",
Summary: fmt.Sprintf("Rejected draft %d", req.DraftPostID),
PostID: originalPostID,
DraftPostID: req.DraftPostID,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "rejected",
@@ -493,7 +611,405 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": "1.0.0",
"version": s.version,
})
}
// handleListAudit handles GET /api/v1/audit?limit=100
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
if s.audit == nil {
http.Error(w, "audit storage not configured", http.StatusNotImplemented)
return
}
limit := 100
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
limit = parsed
}
}
if limit > 500 {
limit = 500
}
records, err := s.audit.List(limit)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list audit records: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(records),
"records": records,
})
}
// handleListIdeas handles GET /api/v1/ideas?status=new
func (s *Server) handleListIdeas(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
status := r.URL.Query().Get("status")
ideas, err := s.ideaSvc.ListIdeas(status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list ideas: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(ideas),
"ideas": ideas,
})
}
// handleGenerateIdeas handles POST /api/v1/ideas/generate
func (s *Server) handleGenerateIdeas(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
ideas, err := s.ideaSvc.GenerateDailyIdeas(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate ideas: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "ideas_generated",
Summary: fmt.Sprintf("Generated %d ideas", len(ideas)),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(ideas),
"ideas": ideas,
})
}
// handleGenerateIdeaDraft handles POST /api/v1/ideas/{id}/draft
func (s *Server) handleGenerateIdeaDraft(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
idea, err := s.ideaSvc.GenerateDrafts(r.Context(), id)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate draft: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "idea_draft_generated",
Summary: fmt.Sprintf("Generated draft for idea %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(idea)
}
// handlePublishIdeaDraft handles POST /api/v1/ideas/{id}/publish
func (s *Server) handlePublishIdeaDraft(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
if s.optimizer != nil && s.optimizer.IsDryRun() {
http.Error(w, "dry-run enabled: publish is disabled", http.StatusBadRequest)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
idea, err := s.ideaSvc.PublishDrafts(r.Context(), id)
if err != nil {
http.Error(w, fmt.Sprintf("failed to publish drafts: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "idea_published",
Summary: fmt.Sprintf("Published drafts for idea %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(idea)
}
// handleDeleteIdea handles POST /api/v1/ideas/{id}/delete
func (s *Server) handleDeleteIdea(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := s.ideaSvc.DeleteIdea(r.Context(), id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete idea: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "idea_deleted",
Summary: fmt.Sprintf("Deleted idea %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "deleted",
})
}
// handleListOutreach handles GET /api/v1/outreach?status=new
func (s *Server) handleListOutreach(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
status := r.URL.Query().Get("status")
items, err := s.ideaSvc.ListOutreach(status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list outreach suggestions: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(items),
"items": items,
})
}
// handleGenerateOutreach handles POST /api/v1/outreach/generate
func (s *Server) handleGenerateOutreach(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
items, err := s.ideaSvc.GenerateOutreachSuggestions(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate outreach suggestions: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "outreach_generated",
Summary: fmt.Sprintf("Generated %d outreach suggestions", len(items)),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(items),
"items": items,
})
}
// handleDeleteOutreach handles POST /api/v1/outreach/{id}/delete
func (s *Server) handleDeleteOutreach(w http.ResponseWriter, r *http.Request) {
if s.ideaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := s.ideaSvc.DeleteOutreach(id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete outreach suggestion: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "outreach_deleted",
Summary: fmt.Sprintf("Deleted outreach suggestion %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "deleted",
})
}
// handleListNewsletter handles GET /api/v1/newsletter?status=draft
func (s *Server) handleListNewsletter(w http.ResponseWriter, r *http.Request) {
if s.newsSvc == nil {
http.Error(w, "newsletter service not configured", http.StatusNotImplemented)
return
}
status := r.URL.Query().Get("status")
items, err := s.newsSvc.ListDrafts(status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list newsletters: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(items),
"items": items,
})
}
// handleGenerateNewsletter handles POST /api/v1/newsletter/generate
func (s *Server) handleGenerateNewsletter(w http.ResponseWriter, r *http.Request) {
if s.newsSvc == nil {
http.Error(w, "newsletter service not configured", http.StatusNotImplemented)
return
}
item, err := s.newsSvc.GenerateWeeklyDraft(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate newsletter: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "newsletter_generated",
Summary: fmt.Sprintf("Generated newsletter draft %s", item.ID),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
// handleDeleteNewsletter handles POST /api/v1/newsletter/{id}/delete
func (s *Server) handleDeleteNewsletter(w http.ResponseWriter, r *http.Request) {
if s.newsSvc == nil {
http.Error(w, "newsletter service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := s.newsSvc.DeleteDraft(id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete newsletter: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(r, &storage.AuditRecord{
Action: "newsletter_deleted",
Summary: fmt.Sprintf("Deleted newsletter draft %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "deleted",
})
}
// handleGetDraft handles GET /api/v1/drafts/{id}
func (s *Server) handleGetDraft(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
if idStr == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
draftID, err := strconv.Atoi(idStr)
if err != nil || draftID <= 0 {
http.Error(w, "invalid draft id", http.StatusBadRequest)
return
}
post, err := s.wpClient.GetPostForEdit(r.Context(), draftID, "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch draft: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": post.ID,
"status": post.Status,
"title": post.Title.Rendered,
"excerpt": post.Excerpt.Rendered,
"content": post.Content.Rendered,
})
}
// handleGetComparison handles GET /api/v1/compare/{draft_id}
func (s *Server) handleGetComparison(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["draft_id"]
if idStr == "" {
http.Error(w, "draft_id is required", http.StatusBadRequest)
return
}
draftID, err := strconv.Atoi(idStr)
if err != nil || draftID <= 0 {
http.Error(w, "invalid draft id", http.StatusBadRequest)
return
}
ctx := r.Context()
draftPost, err := s.wpClient.GetPostForEdit(ctx, draftID, "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch draft: %v", err), http.StatusInternalServerError)
return
}
record, recordErr := s.storage.FindByDraftID(draftID)
originalPostID := 0
if recordErr == nil && record != nil {
originalPostID = record.OriginalPostID
}
if originalPostID == 0 {
originalPostID, err = s.getOriginalPostIDForDraft(ctx, draftID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to resolve original post: %v", err), http.StatusInternalServerError)
return
}
}
originalPost, err := s.wpClient.GetPostForEdit(ctx, originalPostID, "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch original post: %v", err), http.StatusInternalServerError)
return
}
originalSEOTitle := getMetaString(originalPost.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
if originalSEOTitle == "" {
originalSEOTitle = originalPost.Title.Rendered
}
originalSEODescription := getMetaString(originalPost.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
if originalSEODescription == "" {
originalSEODescription = originalPost.Excerpt.Rendered
}
draftSEOTitle := getMetaString(draftPost.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
draftSEODescription := getMetaString(draftPost.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
if recordErr == nil && record != nil && record.Optimization != nil {
if record.Optimization.SEOMeta.Title != "" {
draftSEOTitle = record.Optimization.SEOMeta.Title
}
if record.Optimization.SEOMeta.Description != "" {
draftSEODescription = record.Optimization.SEOMeta.Description
}
}
if draftSEOTitle == "" {
draftSEOTitle = draftPost.Title.Rendered
}
if draftSEODescription == "" {
draftSEODescription = draftPost.Excerpt.Rendered
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"draft": map[string]interface{}{
"id": draftPost.ID,
"title": draftPost.Title.Rendered,
"content": draftPost.Content.Rendered,
"seo_title": draftSEOTitle,
"seo_description": draftSEODescription,
},
"original": map[string]interface{}{
"id": originalPost.ID,
"title": originalPost.Title.Rendered,
"content": originalPost.Content.Rendered,
"seo_title": originalSEOTitle,
"seo_description": originalSEODescription,
},
})
}
@@ -511,8 +1027,8 @@ func (s *Server) runOptimizationJob(job *Job) {
err := s.optimizer.OptimizeSpecificPost(ctx, job.PostID, job.Language)
// Update job status
var auditRecord *storage.AuditRecord
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
now := time.Now()
job.EndedAt = &now
@@ -526,6 +1042,16 @@ func (s *Server) runOptimizationJob(job *Job) {
"job_id", job.ID,
"reason", skipErr.Reason,
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_skipped",
Summary: job.Summary,
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
s.jobsMu.Unlock()
s.recordAudit(nil, auditRecord)
return
}
@@ -535,6 +1061,14 @@ func (s *Server) runOptimizationJob(job *Job) {
"job_id", job.ID,
"error", err,
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_failed",
Summary: fmt.Sprintf("Optimization failed for post %d: %s", job.PostID, err.Error()),
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
} else {
job.Status = "completed"
job.Summary = "Optimization completed successfully"
@@ -542,6 +1076,18 @@ func (s *Server) runOptimizationJob(job *Job) {
"job_id", job.ID,
"duration", now.Sub(job.StartedAt),
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_completed",
Summary: job.Summary,
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
}
s.jobsMu.Unlock()
if auditRecord != nil {
s.recordAudit(nil, auditRecord)
}
}
+18
View File
@@ -0,0 +1,18 @@
package api
import (
"embed"
"io/fs"
"net/http"
)
//go:embed ui/*
var uiFiles embed.FS
func uiFileServer() (http.Handler, error) {
sub, err := fs.Sub(uiFiles, "ui")
if err != nil {
return nil, err
}
return http.FileServer(http.FS(sub)), nil
}
+604
View File
@@ -0,0 +1,604 @@
const apiFetch = async (path, options = {}) => {
const res = await fetch(path, {
headers: { "Content-Type": "application/json", "X-WPSK-Client": "webui" },
...options,
});
const text = await res.text();
if (!res.ok) {
throw new Error(text || res.statusText);
}
try {
return JSON.parse(text);
} catch {
return text;
}
};
const byId = (id) => document.getElementById(id);
const state = {
pending: [],
ideas: [],
outreach: [],
newsletters: [],
audit: [],
};
const appendOutput = (text) => {
const output = byId("command-output");
output.textContent = `${output.textContent}${text}\n`;
output.scrollTop = output.scrollHeight;
};
const setOutput = (text) => {
const output = byId("command-output");
output.textContent = text;
};
const stripHTML = (html) => {
const div = document.createElement("div");
div.innerHTML = 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 modalState = {
mode: "full",
type: "text",
data: null,
};
const renderModal = () => {
const body = byId("modal-body");
body.innerHTML = "";
const fullBtn = byId("modal-mode-full");
const compareBtn = byId("modal-mode-compare");
const toggle = byId("modal-toggle");
fullBtn.classList.toggle("active", modalState.mode === "full");
compareBtn.classList.toggle("active", modalState.mode === "compare");
if (modalState.type === "text") {
if (toggle) {
toggle.classList.add("hidden");
}
const pre = document.createElement("pre");
pre.className = "modal-text";
pre.textContent = modalState.data?.body || "";
body.appendChild(pre);
return;
}
if (toggle) {
toggle.classList.remove("hidden");
}
const data = modalState.data || {};
const draft = data.draft || {};
const original = data.original || {};
if (modalState.mode === "full") {
const seoBlock = document.createElement("div");
seoBlock.className = "modal-text";
seoBlock.textContent = `SEO Title:\n${draft.seo_title || ""}\n\nSEO Description:\n${draft.seo_description || ""}`;
body.appendChild(seoBlock);
const content = document.createElement("pre");
content.className = "modal-text";
content.textContent = stripHTML(draft.content || "");
body.appendChild(content);
return;
}
const grid = document.createElement("div");
grid.className = "compare-grid";
const makeCol = (label, item) => {
const col = document.createElement("div");
col.className = "compare-col";
const title = document.createElement("h4");
title.textContent = label;
col.appendChild(title);
const meta = document.createElement("div");
meta.className = "compare-meta";
meta.textContent = item.title || "";
col.appendChild(meta);
const seo = document.createElement("div");
seo.className = "modal-text diff-block";
const content = document.createElement("div");
content.className = "modal-text diff-block";
col.appendChild(seo);
col.appendChild(content);
return col;
};
const originalCol = makeCol("Original", original);
const draftCol = makeCol("Draft", draft);
const originalBlocks = originalCol.querySelectorAll(".diff-block");
const draftBlocks = draftCol.querySelectorAll(".diff-block");
const originalTitle = original.seo_title || "";
const draftTitle = draft.seo_title || "";
const originalDesc = original.seo_description || "";
const draftDesc = draft.seo_description || "";
const originalContent = stripHTML(original.content || "");
const draftContent = stripHTML(draft.content || "");
originalBlocks[0].innerHTML = `SEO Title:\n${renderDiffHTML(originalTitle, draftTitle, "original")}\n\nSEO Description:\n${renderDiffHTML(originalDesc, draftDesc, "original")}`;
draftBlocks[0].innerHTML = `SEO Title:\n${renderDiffHTML(originalTitle, draftTitle, "draft")}\n\nSEO Description:\n${renderDiffHTML(originalDesc, draftDesc, "draft")}`;
originalBlocks[1].innerHTML = renderDiffHTML(originalContent, draftContent, "original");
draftBlocks[1].innerHTML = renderDiffHTML(originalContent, draftContent, "draft");
grid.appendChild(originalCol);
grid.appendChild(draftCol);
body.appendChild(grid);
};
const showModalText = (title, body) => {
modalState.type = "text";
modalState.mode = "full";
modalState.data = { body };
byId("modal-title").textContent = title;
byId("modal").classList.remove("hidden");
renderModal();
};
const showModalComparison = (data) => {
modalState.type = "compare";
modalState.mode = "full";
modalState.data = data;
byId("modal-title").textContent = data?.draft?.title || "Draft";
byId("modal").classList.remove("hidden");
renderModal();
};
const hideModal = () => byId("modal").classList.add("hidden");
const renderPending = () => {
const list = byId("pending-list");
list.innerHTML = "";
state.pending.forEach((item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.post_title || "Untitled"}</h3>
<div class="meta">Draft ID: ${item.draft_post_id} • Post ID: ${item.original_post_id}</div>
<div class="actions">
<button data-view="${item.draft_post_id}">View</button>
<button data-approve="${item.draft_post_id}">Approve</button>
<button class="danger" data-reject="${item.draft_post_id}">Reject</button>
</div>
`;
list.appendChild(el);
});
};
const renderIdeas = () => {
const list = byId("ideas-list");
list.innerHTML = "";
state.ideas.forEach((item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.seo_title}</h3>
<div class="meta">${item.status}${new Date(item.created_at).toLocaleString()}</div>
<div class="actions">
<button data-idea-draft="${item.id}">Draft</button>
<button data-idea-publish="${item.id}">Publish</button>
<button class="danger" data-idea-delete="${item.id}">Delete</button>
</div>
`;
list.appendChild(el);
});
};
const renderOutreach = () => {
const list = byId("outreach-list");
list.innerHTML = "";
state.outreach.forEach((item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.reddit_title || "Reddit Thread"}</h3>
<div class="meta">${item.article_title || "Article"}</div>
<div class="actions">
<button data-outreach-view="${item.id}">View</button>
<button class="danger" data-outreach-delete="${item.id}">Delete</button>
</div>
`;
list.appendChild(el);
});
};
const renderNewsletters = () => {
const list = byId("newsletter-list");
list.innerHTML = "";
state.newsletters.forEach((item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.subject || "Newsletter Draft"}</h3>
<div class="meta">${item.status}${new Date(item.created_at).toLocaleString()}</div>
<div class="actions">
<button data-newsletter-view="${item.id}">View</button>
<button class="danger" data-newsletter-delete="${item.id}">Delete</button>
</div>
`;
list.appendChild(el);
});
};
const renderAudit = () => {
const list = byId("audit-list");
list.innerHTML = "";
state.audit.forEach((item) => {
const el = document.createElement("div");
el.className = "item";
const metaParts = [];
if (item.created_at) {
metaParts.push(new Date(item.created_at).toLocaleString());
}
if (item.actor) {
metaParts.push(item.actor);
}
if (item.action) {
metaParts.push(item.action);
}
const idParts = [];
if (item.post_id) {
idParts.push(`Post ${item.post_id}`);
}
if (item.draft_post_id) {
idParts.push(`Draft ${item.draft_post_id}`);
}
if (item.job_id) {
idParts.push(`Job ${item.job_id}`);
}
if (item.language) {
idParts.push(`Lang ${item.language}`);
}
el.innerHTML = `
<h3>${item.summary || item.action || "Audit entry"}</h3>
<div class="meta">${metaParts.join(" • ")}</div>
${idParts.length ? `<div class="meta">${idParts.join(" • ")}</div>` : ""}
`;
list.appendChild(el);
});
};
const loadHealth = async () => {
const res = await apiFetch("/api/v1/health");
byId("server-version").textContent = res.version || "unknown";
};
const loadPending = async () => {
const res = await apiFetch("/api/v1/pending");
state.pending = res.records || [];
renderPending();
};
const loadIdeas = async () => {
const res = await apiFetch("/api/v1/ideas?status=new");
state.ideas = res.ideas || [];
renderIdeas();
};
const loadOutreach = async () => {
const res = await apiFetch("/api/v1/outreach?status=new");
state.outreach = res.items || [];
renderOutreach();
const alert = byId("outreach-alert");
if (res.count > 0) {
alert.textContent = `⚠ Pending outreach suggestions: ${res.count}`;
alert.classList.remove("hidden");
} else {
alert.classList.add("hidden");
}
};
const loadNewsletters = async () => {
const res = await apiFetch("/api/v1/newsletter?status=draft");
state.newsletters = res.items || [];
renderNewsletters();
};
const loadAudit = async () => {
const res = await apiFetch("/api/v1/audit?limit=100");
state.audit = res.records || [];
renderAudit();
};
const refreshAll = async () => {
await Promise.all([loadHealth(), loadPending(), loadIdeas(), loadOutreach(), loadNewsletters(), loadAudit()]);
};
const commandHandlers = {
help: async () =>
"Commands: health, version, pending [summary|text <id>], approve <id>, reject <id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], optimize <post_id> [lang], changes <post_id>, status <job_id>, audit [limit]",
health: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
version: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
pending: async (args) => {
if (args[0] === "text" && args[1]) {
const draft = await apiFetch(`/api/v1/drafts/${args[1]}`);
return `${draft.title}\n\n${stripHTML(draft.content)}`;
}
return JSON.stringify(await apiFetch("/api/v1/pending"), null, 2);
},
approve: async (args) => {
await apiFetch("/api/v1/apply-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(args[0]) }),
});
await loadPending();
return "Draft approved.";
},
reject: async (args) => {
await apiFetch("/api/v1/reject-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(args[0]) }),
});
await loadPending();
return "Draft rejected.";
},
ideas: async (args) => {
if (args[0] === "generate") {
return JSON.stringify(await apiFetch("/api/v1/ideas/generate", { method: "POST" }), null, 2);
}
if (args[0] === "draft" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/ideas/${args[1]}/draft`, { method: "POST" }), null, 2);
}
if (args[0] === "publish" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/ideas/${args[1]}/publish`, { method: "POST" }), null, 2);
}
if (args[0] === "delete" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/ideas/${args[1]}/delete`, { method: "POST" }), null, 2);
}
const status = args[0] || "new";
return JSON.stringify(await apiFetch(`/api/v1/ideas?status=${status}`), null, 2);
},
outreach: async (args) => {
if (args[0] === "generate") {
return JSON.stringify(await apiFetch("/api/v1/outreach/generate", { method: "POST" }), null, 2);
}
if (args[0] === "delete" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/outreach/${args[1]}/delete`, { method: "POST" }), null, 2);
}
const status = args[0] || "new";
return JSON.stringify(await apiFetch(`/api/v1/outreach?status=${status}`), null, 2);
},
newsletter: async (args) => {
if (args[0] === "generate") {
return JSON.stringify(await apiFetch("/api/v1/newsletter/generate", { method: "POST" }), null, 2);
}
if (args[0] === "delete" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/newsletter/${args[1]}/delete`, { method: "POST" }), null, 2);
}
const status = args[0] || "draft";
return JSON.stringify(await apiFetch(`/api/v1/newsletter?status=${status}`), null, 2);
},
optimize: async (args) => {
const payload = { post_id: Number(args[0]), language: args[1] || "en" };
return JSON.stringify(
await apiFetch("/api/v1/optimize", { method: "POST", body: JSON.stringify(payload) }),
null,
2
);
},
changes: async (args) => JSON.stringify(await apiFetch(`/api/v1/optimization/${args[0]}`), null, 2),
status: async (args) => JSON.stringify(await apiFetch(`/api/v1/status/${args[0]}`), null, 2),
audit: async (args) => {
const limit = args[0] ? Number(args[0]) : 100;
return JSON.stringify(await apiFetch(`/api/v1/audit?limit=${limit || 100}`), null, 2);
},
};
const runCommand = async () => {
const input = byId("command-input");
const tokens = input.value.trim().split(/\s+/).filter(Boolean);
if (!tokens.length) return;
const cmd = tokens[0];
const args = tokens.slice(1);
const handler = commandHandlers[cmd];
if (!handler) {
appendOutput(`Unknown command: ${cmd}`);
return;
}
try {
const res = await handler(args);
setOutput(res);
await Promise.all([loadPending(), loadIdeas(), loadOutreach(), loadNewsletters(), loadAudit()]);
} catch (err) {
appendOutput(`Error: ${err.message}`);
}
};
const wireActions = () => {
byId("refresh-all").addEventListener("click", refreshAll);
byId("generate-ideas").addEventListener("click", async () => {
setOutput("Generating ideas...");
await apiFetch("/api/v1/ideas/generate", { method: "POST" });
await loadIdeas();
await loadAudit();
});
byId("generate-outreach").addEventListener("click", async () => {
setOutput("Generating outreach...");
await apiFetch("/api/v1/outreach/generate", { method: "POST" });
await loadOutreach();
await loadAudit();
});
byId("generate-newsletter").addEventListener("click", async () => {
setOutput("Generating newsletter...");
await apiFetch("/api/v1/newsletter/generate", { method: "POST" });
await loadNewsletters();
await loadAudit();
});
byId("refresh-pending").addEventListener("click", loadPending);
byId("refresh-ideas").addEventListener("click", loadIdeas);
byId("refresh-outreach").addEventListener("click", loadOutreach);
byId("refresh-newsletter").addEventListener("click", loadNewsletters);
byId("refresh-audit").addEventListener("click", loadAudit);
byId("command-run").addEventListener("click", runCommand);
byId("command-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
runCommand();
}
});
byId("pending-list").addEventListener("click", async (e) => {
const view = e.target.getAttribute("data-view");
const approve = e.target.getAttribute("data-approve");
const reject = e.target.getAttribute("data-reject");
if (view) {
const comparison = await apiFetch(`/api/v1/compare/${view}`);
showModalComparison(comparison);
}
if (approve) {
await apiFetch("/api/v1/apply-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(approve) }),
});
await loadPending();
await loadAudit();
}
if (reject) {
await apiFetch("/api/v1/reject-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(reject) }),
});
await loadPending();
await loadAudit();
}
});
byId("ideas-list").addEventListener("click", async (e) => {
const draft = e.target.getAttribute("data-idea-draft");
const publish = e.target.getAttribute("data-idea-publish");
const del = e.target.getAttribute("data-idea-delete");
if (draft) {
await apiFetch(`/api/v1/ideas/${draft}/draft`, { method: "POST" });
await loadIdeas();
await loadAudit();
}
if (publish) {
await apiFetch(`/api/v1/ideas/${publish}/publish`, { method: "POST" });
await loadIdeas();
await loadAudit();
}
if (del) {
await apiFetch(`/api/v1/ideas/${del}/delete`, { method: "POST" });
await loadIdeas();
await loadAudit();
}
});
byId("outreach-list").addEventListener("click", async (e) => {
const view = e.target.getAttribute("data-outreach-view");
const del = e.target.getAttribute("data-outreach-delete");
if (view) {
const res = await apiFetch("/api/v1/outreach?status=new");
const item = (res.items || []).find((it) => it.id === view);
if (item) {
showModalText(
"Outreach Suggestion",
`${item.reddit_url}\n\n${item.article_title}\n${item.article_url}\n\n${item.suggested_response}`
);
}
}
if (del) {
await apiFetch(`/api/v1/outreach/${del}/delete`, { method: "POST" });
await loadOutreach();
await loadAudit();
}
});
byId("newsletter-list").addEventListener("click", async (e) => {
const view = e.target.getAttribute("data-newsletter-view");
const del = e.target.getAttribute("data-newsletter-delete");
if (view) {
const res = await apiFetch("/api/v1/newsletter?status=draft");
const item = (res.items || []).find((it) => it.id === view);
if (item) {
showModalText(
"Newsletter Draft",
`${item.subject}\n\n${item.preview_text || ""}\n\n${item.body_text || ""}`
);
}
}
if (del) {
await apiFetch(`/api/v1/newsletter/${del}/delete`, { method: "POST" });
await loadNewsletters();
await loadAudit();
}
});
byId("modal-close").addEventListener("click", hideModal);
byId("modal-mode-full").addEventListener("click", () => {
modalState.mode = "full";
renderModal();
});
byId("modal-mode-compare").addEventListener("click", () => {
modalState.mode = "compare";
renderModal();
});
byId("modal").addEventListener("click", (e) => {
if (e.target.id === "modal") hideModal();
});
};
window.addEventListener("DOMContentLoaded", async () => {
wireActions();
await refreshAll();
});
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WPSideKick UI</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<header class="header">
<div>
<h1>WPSideKick</h1>
<div class="sub">Server: <span id="server-version">-</span></div>
</div>
<div class="actions">
<button id="refresh-all">Refresh</button>
<button id="generate-ideas">Generate Ideas</button>
<button id="generate-outreach">Generate Outreach</button>
<button id="generate-newsletter">Generate Newsletter</button>
</div>
</header>
<div id="outreach-alert" class="alert hidden"></div>
<section class="command">
<label for="command-input">Command Bar</label>
<div class="command-row">
<input id="command-input" placeholder="e.g., pending text 123 or outreach generate" />
<button id="command-run">Run</button>
</div>
<pre id="command-output" class="output"></pre>
</section>
<section class="grid">
<div class="panel">
<div class="panel-header">
<h2>Pending Drafts</h2>
<button id="refresh-pending">Refresh</button>
</div>
<div id="pending-list" class="list"></div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Ideas</h2>
<button id="refresh-ideas">Refresh</button>
</div>
<div id="ideas-list" class="list"></div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Outreach</h2>
<button id="refresh-outreach">Refresh</button>
</div>
<div id="outreach-list" class="list"></div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Newsletter</h2>
<button id="refresh-newsletter">Refresh</button>
</div>
<div id="newsletter-list" class="list"></div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Audit Log</h2>
<button id="refresh-audit">Refresh</button>
</div>
<div id="audit-list" class="list"></div>
</div>
</section>
</div>
<div id="modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Draft</h3>
<div class="modal-actions">
<div class="toggle" id="modal-toggle">
<button id="modal-mode-full" class="active">Full</button>
<button id="modal-mode-compare">Compare</button>
</div>
<button id="modal-close">Close</button>
</div>
</div>
<div id="modal-body" class="modal-body"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
:root {
--bg: #0f1115;
--panel: #171a21;
--text: #f4f4f4;
--muted: #9aa3b2;
--accent: #4ec9b0;
--warn: #f0b429;
--danger: #ff6b6b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(circle at top, #1b1f2a, #0f1115 60%);
color: var(--text);
}
.app {
max-width: 1200px;
margin: 32px auto;
padding: 0 16px 48px;
}
.header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.header h1 {
margin: 0;
font-size: 28px;
}
.sub {
color: var(--muted);
font-size: 13px;
}
.actions button {
margin-left: 8px;
}
.alert {
margin: 16px 0;
padding: 10px 12px;
border: 1px solid var(--warn);
background: rgba(240, 180, 41, 0.15);
color: var(--warn);
border-radius: 6px;
}
.hidden {
display: none;
}
button {
background: var(--panel);
color: var(--text);
border: 1px solid #2a2f3a;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
.command {
margin-top: 24px;
padding: 16px;
background: var(--panel);
border-radius: 8px;
border: 1px solid #222833;
}
.command label {
display: block;
margin-bottom: 8px;
color: var(--muted);
}
.command-row {
display: flex;
gap: 8px;
}
.command input {
flex: 1;
padding: 10px;
border-radius: 6px;
background: #0e1016;
border: 1px solid #2a2f3a;
color: var(--text);
}
.output {
margin-top: 12px;
background: #0e1016;
border: 1px solid #2a2f3a;
padding: 12px;
border-radius: 6px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
margin-top: 20px;
}
.panel {
background: var(--panel);
border-radius: 8px;
border: 1px solid #222833;
padding: 12px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.list {
margin-top: 12px;
display: grid;
gap: 10px;
}
.item {
background: #12151c;
border: 1px solid #222833;
padding: 10px;
border-radius: 6px;
}
.item h3 {
margin: 0 0 6px;
font-size: 15px;
}
.item .meta {
color: var(--muted);
font-size: 12px;
margin-bottom: 8px;
}
.item .actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.danger {
border-color: var(--danger);
color: var(--danger);
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden {
display: none;
}
.modal-content {
width: min(900px, 90vw);
background: var(--panel);
border-radius: 8px;
border: 1px solid #222833;
padding: 16px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-actions {
display: flex;
gap: 10px;
align-items: center;
}
.toggle {
display: inline-flex;
border: 1px solid #2a2f3a;
border-radius: 6px;
overflow: hidden;
}
.toggle button {
border: 0;
background: transparent;
padding: 6px 10px;
}
.toggle button.active {
background: #222833;
color: var(--text);
}
.modal-body {
margin-top: 12px;
display: grid;
gap: 12px;
}
.modal-text {
background: #0e1016;
border: 1px solid #2a2f3a;
padding: 12px;
border-radius: 6px;
max-height: 60vh;
overflow: auto;
white-space: pre-wrap;
}
.diff-add {
background: rgba(78, 201, 176, 0.2);
color: var(--accent);
border-radius: 3px;
padding: 0 2px;
}
.diff-del {
background: rgba(255, 107, 107, 0.2);
color: var(--danger);
border-radius: 3px;
padding: 0 2px;
text-decoration: line-through;
}
.compare-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.compare-col h4 {
margin: 0 0 6px;
font-size: 14px;
}
.compare-meta {
color: var(--muted);
font-size: 12px;
margin-bottom: 8px;
}
@media (max-width: 900px) {
.compare-grid {
grid-template-columns: 1fr;
}
}
+313 -7
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/viper"
@@ -11,10 +12,17 @@ import (
// Config holds the complete application configuration
type Config struct {
WordPress WordPressConfig `mapstructure:"wordpress"`
LLM LLMConfig `mapstructure:"llm"`
Claude ClaudeConfig `mapstructure:"claude"`
DeepSeek DeepSeekConfig `mapstructure:"deepseek"`
Runtime RuntimeConfig `mapstructure:"runtime"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
Optimization OptimizationConfig `mapstructure:"optimization"`
Ideas IdeasConfig `mapstructure:"ideas"`
Outreach OutreachConfig `mapstructure:"outreach"`
Newsletter NewsletterConfig `mapstructure:"newsletter"`
Storage StorageConfig `mapstructure:"storage"`
Audit AuditConfig `mapstructure:"audit"`
API APIConfig `mapstructure:"api"`
Logging LoggingConfig `mapstructure:"logging"`
Notifications NotificationsConfig `mapstructure:"notifications"`
@@ -28,6 +36,16 @@ type WordPressConfig struct {
Timeout time.Duration `mapstructure:"timeout"`
}
// RuntimeConfig holds runtime toggles.
type RuntimeConfig struct {
DryRun bool `mapstructure:"dry_run"`
}
// LLMConfig holds provider selection.
type LLMConfig struct {
Provider string `mapstructure:"provider"`
}
// ClaudeConfig holds Claude API configuration
type ClaudeConfig struct {
APIKey string `mapstructure:"api_key"`
@@ -36,6 +54,15 @@ type ClaudeConfig struct {
Temperature float64 `mapstructure:"temperature"`
}
// DeepSeekConfig holds DeepSeek API configuration
type DeepSeekConfig struct {
APIKey string `mapstructure:"api_key"`
BaseURL string `mapstructure:"base_url"`
Model string `mapstructure:"model"`
MaxTokens int `mapstructure:"max_tokens"`
Temperature float64 `mapstructure:"temperature"`
}
// SchedulerConfig holds scheduling configuration
type SchedulerConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -55,11 +82,60 @@ type OptimizationConfig struct {
SyncTranslations bool `mapstructure:"sync_translations"`
}
// IdeasConfig holds reddit idea generation configuration
type IdeasConfig struct {
Enabled bool `mapstructure:"enabled"`
IntervalHours float64 `mapstructure:"interval_hours"`
Subreddit string `mapstructure:"subreddit"`
Listing string `mapstructure:"listing"`
TimeRange string `mapstructure:"time_range"`
MaxRedditPosts int `mapstructure:"max_reddit_posts"`
MaxIdeas int `mapstructure:"max_ideas"`
StyleSamplePosts int `mapstructure:"style_sample_posts"`
MinScore int `mapstructure:"min_score"`
MinComments int `mapstructure:"min_comments"`
BasePath string `mapstructure:"base_path"`
}
// OutreachConfig holds subreddit outreach configuration.
type OutreachConfig struct {
Enabled bool `mapstructure:"enabled"`
Subreddit string `mapstructure:"subreddit"`
Listing string `mapstructure:"listing"`
TimeRange string `mapstructure:"time_range"`
MaxRedditPosts int `mapstructure:"max_reddit_posts"`
MaxSuggestions int `mapstructure:"max_suggestions"`
MinScore int `mapstructure:"min_score"`
MinComments int `mapstructure:"min_comments"`
MaxArticles int `mapstructure:"max_articles"`
BasePath string `mapstructure:"base_path"`
}
// NewsletterConfig holds weekly newsletter configuration.
type NewsletterConfig struct {
Enabled bool `mapstructure:"enabled"`
IntervalHours float64 `mapstructure:"interval_hours"`
FeedURL string `mapstructure:"feed_url"`
Subreddit string `mapstructure:"subreddit"`
Listing string `mapstructure:"listing"`
TimeRange string `mapstructure:"time_range"`
MaxFeedItems int `mapstructure:"max_feed_items"`
MaxRedditPosts int `mapstructure:"max_reddit_posts"`
MinScore int `mapstructure:"min_score"`
MinComments int `mapstructure:"min_comments"`
BasePath string `mapstructure:"base_path"`
}
// StorageConfig holds storage configuration
type StorageConfig struct {
BasePath string `mapstructure:"base_path"`
}
// AuditConfig holds audit log storage configuration.
type AuditConfig struct {
BasePath string `mapstructure:"base_path"`
}
// APIConfig holds REST API server configuration
type APIConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -96,8 +172,45 @@ func LoadConfig(configPath string) (*Config, error) {
// Bind environment variables to config keys
viper.BindEnv("wordpress.app_password", "WP_APP_PASSWORD")
viper.BindEnv("llm.provider", "LLM_PROVIDER")
viper.BindEnv("claude.api_key", "CLAUDE_API_KEY")
viper.BindEnv("deepseek.api_key", "DEEPSEEK_API_KEY")
viper.BindEnv("deepseek.base_url", "DEEPSEEK_BASE_URL")
viper.BindEnv("runtime.dry_run", "DRY_RUN")
viper.BindEnv("scheduler.interval_hours", "OPTIMIZATION_INTERVAL_HOURS")
viper.BindEnv("ideas.enabled", "IDEAS_ENABLED")
viper.BindEnv("ideas.interval_hours", "IDEAS_INTERVAL_HOURS")
viper.BindEnv("ideas.subreddit", "IDEAS_SUBREDDIT")
viper.BindEnv("ideas.listing", "IDEAS_LISTING")
viper.BindEnv("ideas.time_range", "IDEAS_TIME_RANGE")
viper.BindEnv("ideas.max_reddit_posts", "IDEAS_MAX_REDDIT_POSTS")
viper.BindEnv("ideas.max_ideas", "IDEAS_MAX_IDEAS")
viper.BindEnv("ideas.style_sample_posts", "IDEAS_STYLE_SAMPLES")
viper.BindEnv("ideas.min_score", "IDEAS_MIN_SCORE")
viper.BindEnv("ideas.min_comments", "IDEAS_MIN_COMMENTS")
viper.BindEnv("ideas.base_path", "IDEAS_STORAGE_PATH")
viper.BindEnv("outreach.enabled", "OUTREACH_ENABLED")
viper.BindEnv("outreach.subreddit", "OUTREACH_SUBREDDIT")
viper.BindEnv("outreach.listing", "OUTREACH_LISTING")
viper.BindEnv("outreach.time_range", "OUTREACH_TIME_RANGE")
viper.BindEnv("outreach.max_reddit_posts", "OUTREACH_MAX_REDDIT_POSTS")
viper.BindEnv("outreach.max_suggestions", "OUTREACH_MAX_SUGGESTIONS")
viper.BindEnv("outreach.min_score", "OUTREACH_MIN_SCORE")
viper.BindEnv("outreach.min_comments", "OUTREACH_MIN_COMMENTS")
viper.BindEnv("outreach.max_articles", "OUTREACH_MAX_ARTICLES")
viper.BindEnv("outreach.base_path", "OUTREACH_STORAGE_PATH")
viper.BindEnv("newsletter.enabled", "NEWSLETTER_ENABLED")
viper.BindEnv("newsletter.interval_hours", "NEWSLETTER_INTERVAL_HOURS")
viper.BindEnv("newsletter.feed_url", "NEWSLETTER_FEED_URL")
viper.BindEnv("newsletter.subreddit", "NEWSLETTER_SUBREDDIT")
viper.BindEnv("newsletter.listing", "NEWSLETTER_LISTING")
viper.BindEnv("newsletter.time_range", "NEWSLETTER_TIME_RANGE")
viper.BindEnv("newsletter.max_feed_items", "NEWSLETTER_MAX_FEED_ITEMS")
viper.BindEnv("newsletter.max_reddit_posts", "NEWSLETTER_MAX_REDDIT_POSTS")
viper.BindEnv("newsletter.min_score", "NEWSLETTER_MIN_SCORE")
viper.BindEnv("newsletter.min_comments", "NEWSLETTER_MIN_COMMENTS")
viper.BindEnv("newsletter.base_path", "NEWSLETTER_STORAGE_PATH")
viper.BindEnv("audit.base_path", "AUDIT_STORAGE_PATH")
viper.BindEnv("logging.level", "LOG_LEVEL")
viper.BindEnv("api.port", "API_PORT")
viper.BindEnv("notifications.webhook_url", "WEBHOOK_URL")
@@ -119,6 +232,27 @@ 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"
}
// Validate config
if err := validateConfig(&config); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
@@ -127,6 +261,12 @@ func LoadConfig(configPath string) (*Config, error) {
// 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.Audit.BasePath = os.ExpandEnv(config.Audit.BasePath)
config.Notifications.WebhookURL = os.ExpandEnv(config.Notifications.WebhookURL)
return &config, nil
@@ -138,9 +278,15 @@ func setDefaults() {
viper.SetDefault("wordpress.timeout", 30*time.Second)
// Claude defaults
viper.SetDefault("llm.provider", "claude")
viper.SetDefault("claude.model", "claude-sonnet-4-20250514")
viper.SetDefault("claude.max_tokens", 4096)
viper.SetDefault("claude.temperature", 0.3)
viper.SetDefault("deepseek.base_url", "https://api.deepseek.com")
viper.SetDefault("deepseek.model", "deepseek-chat")
viper.SetDefault("deepseek.max_tokens", 4096)
viper.SetDefault("deepseek.temperature", 0.3)
viper.SetDefault("runtime.dry_run", false)
// Scheduler defaults
viper.SetDefault("scheduler.enabled", true)
@@ -151,14 +297,53 @@ func setDefaults() {
viper.SetDefault("optimization.min_content_length", 500)
viper.SetDefault("optimization.max_age_days", 365)
viper.SetDefault("optimization.min_age_days", 30)
viper.SetDefault("optimization.reoptimization_cooldown_days", 365)
viper.SetDefault("optimization.reoptimization_cooldown_days", 180)
viper.SetDefault("optimization.max_internal_links", 8)
viper.SetDefault("optimization.faq_min_questions", 4)
viper.SetDefault("optimization.faq_max_questions", 6)
viper.SetDefault("optimization.sync_translations", true)
// Ideas defaults
viper.SetDefault("ideas.enabled", true)
viper.SetDefault("ideas.interval_hours", 24.0)
viper.SetDefault("ideas.subreddit", "kubernetes")
viper.SetDefault("ideas.listing", "top")
viper.SetDefault("ideas.time_range", "day")
viper.SetDefault("ideas.max_reddit_posts", 20)
viper.SetDefault("ideas.max_ideas", 5)
viper.SetDefault("ideas.style_sample_posts", 3)
viper.SetDefault("ideas.min_score", 25)
viper.SetDefault("ideas.min_comments", 5)
viper.SetDefault("ideas.base_path", "./data/ideas")
// Outreach defaults
viper.SetDefault("outreach.enabled", true)
viper.SetDefault("outreach.subreddit", "kubernetes")
viper.SetDefault("outreach.listing", "top")
viper.SetDefault("outreach.time_range", "day")
viper.SetDefault("outreach.max_reddit_posts", 20)
viper.SetDefault("outreach.max_suggestions", 5)
viper.SetDefault("outreach.min_score", 25)
viper.SetDefault("outreach.min_comments", 5)
viper.SetDefault("outreach.max_articles", 50)
viper.SetDefault("outreach.base_path", "./data/outreach")
// Newsletter defaults
viper.SetDefault("newsletter.enabled", true)
viper.SetDefault("newsletter.interval_hours", 168.0)
viper.SetDefault("newsletter.feed_url", "https://alexandre-vazquez.com/feed/")
viper.SetDefault("newsletter.subreddit", "kubernetes")
viper.SetDefault("newsletter.listing", "top")
viper.SetDefault("newsletter.time_range", "week")
viper.SetDefault("newsletter.max_feed_items", 5)
viper.SetDefault("newsletter.max_reddit_posts", 10)
viper.SetDefault("newsletter.min_score", 10)
viper.SetDefault("newsletter.min_comments", 2)
viper.SetDefault("newsletter.base_path", "./data/newsletters")
// Storage defaults
viper.SetDefault("storage.base_path", "./data/optimizations")
viper.SetDefault("audit.base_path", "./data/audit")
// API defaults
viper.SetDefault("api.enabled", true)
@@ -182,12 +367,26 @@ func validateConfig(cfg *Config) error {
return fmt.Errorf("wordpress.app_password is required (set WP_APP_PASSWORD environment variable)")
}
// Validate Claude config
if cfg.Claude.APIKey == "" || cfg.Claude.APIKey == "${CLAUDE_API_KEY}" {
return fmt.Errorf("claude.api_key is required (set CLAUDE_API_KEY environment variable)")
}
if cfg.Claude.Model == "" {
return fmt.Errorf("claude.model is required")
switch cfg.LLM.Provider {
case "claude":
if cfg.Claude.APIKey == "" || cfg.Claude.APIKey == "${CLAUDE_API_KEY}" {
return fmt.Errorf("claude.api_key is required (set CLAUDE_API_KEY environment variable)")
}
if cfg.Claude.Model == "" {
return fmt.Errorf("claude.model is required")
}
case "deepseek":
if cfg.DeepSeek.APIKey == "" || cfg.DeepSeek.APIKey == "${DEEPSEEK_API_KEY}" {
return fmt.Errorf("deepseek.api_key is required (set DEEPSEEK_API_KEY environment variable)")
}
if strings.TrimSpace(cfg.DeepSeek.BaseURL) == "" {
return fmt.Errorf("deepseek.base_url is required")
}
if cfg.DeepSeek.Model == "" {
return fmt.Errorf("deepseek.model is required")
}
default:
return fmt.Errorf("llm.provider must be one of: claude, deepseek")
}
// Validate scheduler config
@@ -214,10 +413,117 @@ func validateConfig(cfg *Config) error {
return fmt.Errorf("optimization.reoptimization_cooldown_days must be non-negative")
}
// Validate ideas config
if cfg.Ideas.Enabled {
if cfg.Ideas.IntervalHours <= 0 {
return fmt.Errorf("ideas.interval_hours must be positive")
}
if strings.TrimSpace(cfg.Ideas.Subreddit) == "" {
return fmt.Errorf("ideas.subreddit is required")
}
if cfg.Ideas.MaxRedditPosts <= 0 {
return fmt.Errorf("ideas.max_reddit_posts must be positive")
}
if cfg.Ideas.MaxIdeas <= 0 {
return fmt.Errorf("ideas.max_ideas must be positive")
}
if cfg.Ideas.StyleSamplePosts <= 0 {
return fmt.Errorf("ideas.style_sample_posts must be positive")
}
if cfg.Ideas.MinScore < 0 {
return fmt.Errorf("ideas.min_score must be non-negative")
}
if cfg.Ideas.MinComments < 0 {
return fmt.Errorf("ideas.min_comments must be non-negative")
}
if strings.TrimSpace(cfg.Ideas.BasePath) == "" {
return fmt.Errorf("ideas.base_path is required")
}
listing := strings.ToLower(strings.TrimSpace(cfg.Ideas.Listing))
if listing != "top" && listing != "hot" && listing != "new" {
return fmt.Errorf("ideas.listing must be one of: top, hot, new")
}
timeRange := strings.ToLower(strings.TrimSpace(cfg.Ideas.TimeRange))
if listing == "top" && timeRange == "" {
return fmt.Errorf("ideas.time_range is required when listing=top")
}
}
// Validate outreach config
if cfg.Outreach.Enabled {
if strings.TrimSpace(cfg.Outreach.Subreddit) == "" {
return fmt.Errorf("outreach.subreddit is required")
}
if cfg.Outreach.MaxRedditPosts <= 0 {
return fmt.Errorf("outreach.max_reddit_posts must be positive")
}
if cfg.Outreach.MaxSuggestions <= 0 {
return fmt.Errorf("outreach.max_suggestions must be positive")
}
if cfg.Outreach.MinScore < 0 {
return fmt.Errorf("outreach.min_score must be non-negative")
}
if cfg.Outreach.MinComments < 0 {
return fmt.Errorf("outreach.min_comments must be non-negative")
}
if cfg.Outreach.MaxArticles <= 0 {
return fmt.Errorf("outreach.max_articles must be positive")
}
if strings.TrimSpace(cfg.Outreach.BasePath) == "" {
return fmt.Errorf("outreach.base_path is required")
}
listing := strings.ToLower(strings.TrimSpace(cfg.Outreach.Listing))
if listing != "top" && listing != "hot" && listing != "new" {
return fmt.Errorf("outreach.listing must be one of: top, hot, new")
}
timeRange := strings.ToLower(strings.TrimSpace(cfg.Outreach.TimeRange))
if listing == "top" && timeRange == "" {
return fmt.Errorf("outreach.time_range is required when listing=top")
}
}
// Validate newsletter config
if cfg.Newsletter.Enabled {
if cfg.Newsletter.IntervalHours <= 0 {
return fmt.Errorf("newsletter.interval_hours must be positive")
}
if strings.TrimSpace(cfg.Newsletter.FeedURL) == "" {
return fmt.Errorf("newsletter.feed_url is required")
}
if cfg.Newsletter.MaxFeedItems <= 0 {
return fmt.Errorf("newsletter.max_feed_items must be positive")
}
if strings.TrimSpace(cfg.Newsletter.Subreddit) == "" {
return fmt.Errorf("newsletter.subreddit is required")
}
listing := strings.ToLower(strings.TrimSpace(cfg.Newsletter.Listing))
if listing != "top" && listing != "hot" && listing != "new" {
return fmt.Errorf("newsletter.listing must be one of: top, hot, new")
}
if listing == "top" && strings.TrimSpace(cfg.Newsletter.TimeRange) == "" {
return fmt.Errorf("newsletter.time_range is required when listing=top")
}
if cfg.Newsletter.MaxRedditPosts < 0 {
return fmt.Errorf("newsletter.max_reddit_posts must be non-negative")
}
if cfg.Newsletter.MinScore < 0 {
return fmt.Errorf("newsletter.min_score must be non-negative")
}
if cfg.Newsletter.MinComments < 0 {
return fmt.Errorf("newsletter.min_comments must be non-negative")
}
if strings.TrimSpace(cfg.Newsletter.BasePath) == "" {
return fmt.Errorf("newsletter.base_path is required")
}
}
// Validate storage config
if cfg.Storage.BasePath == "" {
return fmt.Errorf("storage.base_path is required")
}
if cfg.Audit.BasePath == "" {
return fmt.Errorf("audit.base_path is required")
}
// Validate logging config
if cfg.Logging.Level != "INFO" && cfg.Logging.Level != "DEBUG" {
+213
View File
@@ -0,0 +1,213 @@
package ideas
import (
"bytes"
"strings"
"text/template"
)
// StyleSample represents a reference post for style guidance.
type StyleSample struct {
Title string
Excerpt string
Content string
}
type IdeaPromptData struct {
Subreddit string
MaxIdeas int
RedditPosts []RedditPromptPost
StyleSamples []StyleSample
}
type RedditPromptPost struct {
Title string
URL string
SelfText string
Author string
Score int
Comments int
CreatedAt string
}
type ArticlePromptData struct {
Language string
IdeaTitle string
IdeaSummary string
PrimaryKW string
Sources []string
StyleSamples []StyleSample
}
type OutreachPromptData struct {
Subreddit string
MaxSuggestions int
RedditPosts []RedditPromptPost
Articles []ArticleRef
}
type ArticleRef struct {
Title string
URL string
Excerpt string
}
const ideaPromptTemplate = `You are an expert SEO editor for a technical blog about Kubernetes, DevOps, platform engineering, and enterprise infrastructure.
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.
Style references (recent posts from the blog):
{{range .StyleSamples -}}
- Title: {{.Title}}
Excerpt: {{.Excerpt}}
Content Snippet: {{.Content}}
{{end}}
Reddit sources from r/{{.Subreddit}} (today's discussions):
{{range .RedditPosts -}}
- {{.Title}} ({{.URL}})
Author: {{.Author}} | Score: {{.Score}} | Comments: {{.Comments}} | Created: {{.CreatedAt}}
Context: {{.SelfText}}
{{end}}
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.)
JSON format:
{
"ideas": [
{
"seo_title": "string (50-60 chars, keyword-forward)",
"summary": "string (2-4 sentences describing the post idea)",
"primary_keyword": "string",
"sources": [
{
"title": "string",
"url": "string",
"subreddit": "r/{{.Subreddit}}",
"author": "string",
"score": number,
"created_at": "string"
}
],
"references": ["https://example.com", "https://example.org"]
}
]
}`
const articlePromptTemplate = `You are a senior technical writer for alexandre-vazquez.com.
Write a full blog post in {{.Language}} that matches the author's style, depth, and structure.
Use the idea below and the sources provided. The output must be production-ready HTML.
Idea:
- SEO Title: {{.IdeaTitle}}
- Summary: {{.IdeaSummary}}
- Primary Keyword: {{.PrimaryKW}}
- Sources: {{range .Sources}}{{.}} {{end}}
Style references (recent posts from the blog):
{{range .StyleSamples -}}
- Title: {{.Title}}
Excerpt: {{.Excerpt}}
Content Snippet: {{.Content}}
{{end}}
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.
- 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}}
JSON format:
{
"language": "{{.Language}}",
"title": "string",
"seo_title": "string",
"meta_description": "string",
"excerpt": "string",
"content_html": "string",
"references": ["https://example.com"]
}`
const outreachPromptTemplate = `You are a community SEO specialist.
Goal: Identify Reddit conversations where one of the existing articles would help. Suggest a short, helpful reply that includes the article link.
Principle: Participate by answering, not promoting. If the link is relevant, include it as: "escribí sobre esto en detalle aquí: <link>" (avoid spam).
Reddit sources from r/{{.Subreddit}}:
{{range .RedditPosts -}}
- {{.Title}} ({{.URL}})
Author: {{.Author}} | Score: {{.Score}} | Comments: {{.Comments}} | Created: {{.CreatedAt}}
Context: {{.SelfText}}
{{end}}
Available articles to reference:
{{range .Articles -}}
- {{.Title}} ({{.URL}}) {{.Excerpt}}
{{end}}
Output requirements:
- Return ONLY valid JSON (no markdown)
- Provide up to {{.MaxSuggestions}} suggestions
- Each suggestion must include the Reddit URL, article URL, and a short reply message that is helpful (not spammy)
JSON format:
{
"suggestions": [
{
"reddit_url": "string",
"reddit_title": "string",
"article_title": "string",
"article_url": "string",
"suggested_response": "string (2-4 sentences, include the article URL)"
}
]
}`
func BuildIdeaPrompt(data IdeaPromptData) (string, error) {
tmpl, err := template.New("idea").Parse(ideaPromptTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func BuildArticlePrompt(data ArticlePromptData) (string, error) {
tmpl, err := template.New("article").Parse(articlePromptTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func BuildOutreachPrompt(data OutreachPromptData) (string, error) {
tmpl, err := template.New("outreach").Parse(outreachPromptTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
+490
View File
@@ -0,0 +1,490 @@
package ideas
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/reddit"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
// Service orchestrates idea generation and drafting.
type Service struct {
wpClient *wordpress.Client
llm agent.LLMClient
reddit *reddit.Client
store *storage.IdeaStorage
outreach *storage.OutreachStorage
cfg config.IdeasConfig
outreachCfg config.OutreachConfig
dryRun bool
logger *logger.Logger
}
func NewService(
wpClient *wordpress.Client,
llm agent.LLMClient,
redditClient *reddit.Client,
store *storage.IdeaStorage,
cfg config.IdeasConfig,
outreachStore *storage.OutreachStorage,
outreachCfg config.OutreachConfig,
dryRun bool,
log *logger.Logger,
) *Service {
if log == nil {
log = logger.NewDefaultLogger()
}
if redditClient == nil {
redditClient = reddit.NewClient("", log)
}
return &Service{
wpClient: wpClient,
llm: llm,
reddit: redditClient,
store: store,
cfg: cfg,
outreach: outreachStore,
outreachCfg: outreachCfg,
dryRun: dryRun,
logger: log.WithComponent("ideas"),
}
}
// GenerateDailyIdeas fetches Reddit posts and creates new idea records.
func (s *Service) GenerateDailyIdeas(ctx context.Context) ([]*models.PostIdea, error) {
if !s.cfg.Enabled {
return nil, fmt.Errorf("ideas are disabled")
}
posts, err := s.reddit.FetchPosts(ctx, s.cfg.Subreddit, s.cfg.Listing, s.cfg.TimeRange, s.cfg.MaxRedditPosts)
if err != nil {
return nil, err
}
filtered := make([]reddit.Post, 0, len(posts))
for _, post := range posts {
if post.Score < s.cfg.MinScore {
continue
}
if post.NumComments < s.cfg.MinComments {
continue
}
filtered = append(filtered, post)
}
if len(filtered) == 0 {
return nil, fmt.Errorf("no reddit posts met threshold (min_score=%d, min_comments=%d)", s.cfg.MinScore, s.cfg.MinComments)
}
styleSamples, err := s.fetchStyleSamples(ctx)
if err != nil {
s.logger.Warn("Failed to fetch style samples", "error", err)
}
promptData := IdeaPromptData{
Subreddit: s.cfg.Subreddit,
MaxIdeas: s.cfg.MaxIdeas,
StyleSamples: styleSamples,
}
for _, post := range filtered {
promptData.RedditPosts = append(promptData.RedditPosts, RedditPromptPost{
Title: post.Title,
URL: post.URL,
SelfText: truncate(post.SelfText, 800),
Author: post.Author,
Score: post.Score,
Comments: post.NumComments,
CreatedAt: time.Unix(post.CreatedUTC, 0).Format(time.RFC3339),
})
}
prompt, err := BuildIdeaPrompt(promptData)
if err != nil {
return nil, fmt.Errorf("failed to build idea prompt: %w", err)
}
ideas, err := s.llm.GeneratePostIdeas(ctx, prompt)
if err != nil {
return nil, err
}
if len(ideas) > s.cfg.MaxIdeas {
ideas = ideas[:s.cfg.MaxIdeas]
}
stored := make([]*models.PostIdea, 0, len(ideas))
for _, idea := range ideas {
idea.ID = uuid.New().String()
idea.Status = "new"
idea.CreatedAt = time.Now()
idea.UpdatedAt = time.Now()
if err := s.store.Save(idea); err != nil {
s.logger.Warn("Failed to save idea", "error", err, "title", idea.SEOTitle)
continue
}
stored = append(stored, idea)
}
s.logger.Info("Stored ideas", "count", len(stored))
return stored, nil
}
// GenerateOutreachSuggestions finds Reddit threads where an existing article could help.
func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.OutreachSuggestion, error) {
if s.outreach == nil {
return nil, fmt.Errorf("outreach storage not configured")
}
if !s.outreachCfg.Enabled {
return nil, fmt.Errorf("outreach is disabled")
}
cfg := s.outreachCfg
posts, err := s.reddit.FetchPosts(ctx, cfg.Subreddit, cfg.Listing, cfg.TimeRange, cfg.MaxRedditPosts)
if err != nil {
return nil, err
}
filtered := make([]reddit.Post, 0, len(posts))
for _, post := range posts {
if post.Score < cfg.MinScore {
continue
}
if post.NumComments < cfg.MinComments {
continue
}
filtered = append(filtered, post)
}
if len(filtered) == 0 {
return nil, fmt.Errorf("no reddit posts met outreach threshold (min_score=%d, min_comments=%d)", cfg.MinScore, cfg.MinComments)
}
articles, err := s.wpClient.GetInternalPosts(ctx, "en")
if err != nil {
return nil, fmt.Errorf("failed to fetch internal posts: %w", err)
}
if cfg.MaxArticles > 0 && len(articles) > cfg.MaxArticles {
articles = articles[:cfg.MaxArticles]
}
articleRefs := make([]ArticleRef, 0, len(articles))
for _, article := range articles {
articleRefs = append(articleRefs, ArticleRef{
Title: strings.TrimSpace(article.Title),
URL: article.Link,
Excerpt: truncate(stripHTML(article.Excerpt), 220),
})
}
promptData := OutreachPromptData{
Subreddit: cfg.Subreddit,
MaxSuggestions: cfg.MaxSuggestions,
Articles: articleRefs,
}
for _, post := range filtered {
link := post.PermalinkURL
if link == "" {
link = post.URL
}
promptData.RedditPosts = append(promptData.RedditPosts, RedditPromptPost{
Title: post.Title,
URL: link,
SelfText: truncate(post.SelfText, 800),
Author: post.Author,
Score: post.Score,
Comments: post.NumComments,
CreatedAt: time.Unix(post.CreatedUTC, 0).Format(time.RFC3339),
})
}
prompt, err := BuildOutreachPrompt(promptData)
if err != nil {
return nil, fmt.Errorf("failed to build outreach prompt: %w", err)
}
suggestions, err := s.llm.GenerateOutreachSuggestions(ctx, prompt)
if err != nil {
return nil, err
}
if len(suggestions) > cfg.MaxSuggestions {
suggestions = suggestions[:cfg.MaxSuggestions]
}
stored := make([]*models.OutreachSuggestion, 0, len(suggestions))
for _, suggestion := range suggestions {
suggestion.ID = uuid.New().String()
suggestion.Status = "new"
suggestion.CreatedAt = time.Now()
suggestion.UpdatedAt = time.Now()
if err := s.outreach.Save(suggestion); err != nil {
s.logger.Warn("Failed to save outreach suggestion", "error", err, "reddit_url", suggestion.RedditURL)
continue
}
stored = append(stored, suggestion)
}
s.logger.Info("Stored outreach suggestions", "count", len(stored))
return stored, nil
}
// ListOutreach returns outreach suggestions by status.
func (s *Service) ListOutreach(status string) ([]*models.OutreachSuggestion, error) {
if s.outreach == nil {
return nil, fmt.Errorf("outreach storage not configured")
}
if status == "" {
status = "new"
}
return s.outreach.List(status)
}
// DeleteOutreach removes an outreach suggestion.
func (s *Service) DeleteOutreach(id string) error {
if s.outreach == nil {
return fmt.Errorf("outreach storage not configured")
}
return s.outreach.Delete(id)
}
// ListIdeas returns ideas by status.
func (s *Service) ListIdeas(status string) ([]*models.PostIdea, error) {
if status == "" {
status = "new"
}
return s.store.List(status)
}
// DeleteIdea removes an idea and any draft posts.
func (s *Service) DeleteIdea(ctx context.Context, id string) error {
idea, err := s.store.Get(id)
if err != nil {
return err
}
if idea.DraftPostID > 0 {
_ = s.wpClient.DeleteDraft(ctx, idea.DraftPostID)
}
if idea.DraftPostIDEs > 0 {
_ = s.wpClient.DeleteDraft(ctx, idea.DraftPostIDEs)
}
return s.store.Delete(id)
}
// GenerateDrafts creates draft posts in WordPress for the idea.
func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostIdea, error) {
idea, err := s.store.Get(id)
if err != nil {
return nil, err
}
styleSamples, err := s.fetchStyleSamples(ctx)
if err != nil {
s.logger.Warn("Failed to fetch style samples", "error", err)
}
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)
}
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)
}
enDraft, err := s.llm.GenerateArticleDraft(ctx, enPrompt)
if err != nil {
return nil, err
}
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)
}
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)
}
}
idea.ArticleEN = enDraft
idea.ArticleES = esDraft
idea.DraftPostID = enID
idea.DraftPostIDEs = esID
idea.Status = "drafted"
idea.UpdatedAt = time.Now()
if err := s.store.Delete(idea.ID); err != nil {
s.logger.Debug("Failed to remove old idea record", "id", idea.ID, "error", err)
}
if err := s.store.Save(idea); err != nil {
return nil, err
}
return idea, nil
}
// PublishDrafts publishes the drafts created for the idea.
func (s *Service) PublishDrafts(ctx context.Context, id string) (*models.PostIdea, error) {
idea, err := s.store.Get(id)
if err != nil {
return nil, err
}
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 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 {
return nil, fmt.Errorf("failed to publish ES draft: %w", err)
}
}
idea.Status = "published"
now := time.Now()
idea.PublishedAt = &now
idea.UpdatedAt = time.Now()
if err := s.store.Delete(idea.ID); err != nil {
s.logger.Debug("Failed to remove old idea record", "id", idea.ID, "error", err)
}
if err := s.store.Save(idea); err != nil {
return nil, err
}
return idea, nil
}
func (s *Service) fetchStyleSamples(ctx context.Context) ([]StyleSample, error) {
posts, err := s.wpClient.ListPosts(ctx, wordpress.ListParams{
Status: "publish",
OrderBy: "date",
Order: "desc",
PerPage: s.cfg.StyleSamplePosts,
})
if err != nil {
return nil, err
}
samples := make([]StyleSample, 0, len(posts))
for _, post := range posts {
samples = append(samples, StyleSample{
Title: strings.TrimSpace(post.Title.Rendered),
Excerpt: truncate(stripHTML(post.Excerpt.Rendered), 300),
Content: truncate(stripHTML(post.Content.Rendered), 800),
})
}
return samples, nil
}
func ideaSourceURLs(idea *models.PostIdea) []string {
var urls []string
for _, src := range idea.Sources {
if src.URL != "" {
urls = append(urls, src.URL)
}
}
for _, ref := range idea.References {
if ref != "" {
urls = append(urls, ref)
}
}
return urls
}
func chooseExcerpt(draft *models.ArticleDraft) string {
if draft.Excerpt != "" {
return draft.Excerpt
}
return draft.MetaDescription
}
func truncate(value string, max int) string {
if len(value) <= max {
return value
}
return value[:max] + "..."
}
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())
}
+218
View File
@@ -0,0 +1,218 @@
package newsletter
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/reddit"
"seo-optimizer/internal/storage"
"seo-optimizer/pkg/models"
)
// Service orchestrates newsletter generation.
type Service struct {
llm agent.LLMClient
reddit *reddit.Client
store *storage.NewsletterStorage
cfg config.NewsletterConfig
logger *logger.Logger
}
func NewService(
llm agent.LLMClient,
redditClient *reddit.Client,
store *storage.NewsletterStorage,
cfg config.NewsletterConfig,
log *logger.Logger,
) *Service {
if log == nil {
log = logger.NewDefaultLogger()
}
if redditClient == nil {
redditClient = reddit.NewClient("", log)
}
return &Service{
llm: llm,
reddit: redditClient,
store: store,
cfg: cfg,
logger: log.WithComponent("newsletter"),
}
}
// GenerateWeeklyDraft builds a newsletter draft from RSS + Reddit.
func (s *Service) GenerateWeeklyDraft(ctx context.Context) (*models.NewsletterDraft, error) {
if !s.cfg.Enabled {
return nil, fmt.Errorf("newsletter is disabled")
}
feedItems, err := s.fetchFeed(ctx)
if err != nil {
return nil, err
}
redditItems, err := s.fetchReddit(ctx)
if err != nil {
return nil, err
}
prompt, sources := buildPrompt(feedItems, redditItems)
draft, err := s.llm.GenerateNewsletterDraft(ctx, prompt)
if err != nil {
return nil, err
}
draft.ID = uuid.New().String()
draft.Status = "draft"
draft.Sources = sources
draft.CreatedAt = time.Now()
draft.UpdatedAt = time.Now()
if err := s.store.Save(draft); err != nil {
return nil, err
}
return draft, nil
}
// ListDrafts returns stored drafts by status.
func (s *Service) ListDrafts(status string) ([]*models.NewsletterDraft, error) {
if status == "" {
status = "draft"
}
return s.store.List(status)
}
// DeleteDraft removes a draft.
func (s *Service) DeleteDraft(id string) error {
return s.store.Delete(id)
}
func (s *Service) fetchFeed(ctx context.Context) ([]feedItem, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.FeedURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("feed error: %s", string(body))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rss rssFeed
if err := xml.Unmarshal(data, &rss); err != nil {
return nil, fmt.Errorf("failed to parse feed: %w", err)
}
items := make([]feedItem, 0, len(rss.Channel.Items))
for _, item := range rss.Channel.Items {
items = append(items, feedItem{
Title: strings.TrimSpace(item.Title),
Link: strings.TrimSpace(item.Link),
Summary: strings.TrimSpace(item.Description),
Date: strings.TrimSpace(item.PubDate),
})
if len(items) >= s.cfg.MaxFeedItems {
break
}
}
return items, nil
}
func (s *Service) fetchReddit(ctx context.Context) ([]reddit.Post, error) {
if s.cfg.MaxRedditPosts == 0 {
return nil, nil
}
posts, err := s.reddit.FetchPosts(ctx, s.cfg.Subreddit, s.cfg.Listing, s.cfg.TimeRange, s.cfg.MaxRedditPosts)
if err != nil {
return nil, err
}
filtered := make([]reddit.Post, 0, len(posts))
for _, post := range posts {
if post.Score < s.cfg.MinScore {
continue
}
if post.NumComments < s.cfg.MinComments {
continue
}
filtered = append(filtered, post)
}
return filtered, nil
}
type feedItem struct {
Title string
Link string
Summary string
Date string
}
type rssFeed struct {
Channel struct {
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
} `xml:"item"`
} `xml:"channel"`
}
func buildPrompt(feedItems []feedItem, redditItems []reddit.Post) (string, []string) {
var b strings.Builder
b.WriteString("You are an editor creating a weekly Kubernetes newsletter for Substack.\n")
b.WriteString("Return ONLY valid JSON.\n")
b.WriteString(`JSON format:
{
"subject": "string",
"preview_text": "string",
"body_html": "string",
"body_text": "string"
}` + "\n")
b.WriteString("Structure requirements:\n")
b.WriteString("1) Introduction (warm, concise)\n")
b.WriteString("2) Main post of the week (select ONE if multiple) with a deeper summary and key takeaways\n")
b.WriteString("3) 2-3 community context items from Reddit with context + links to original projects/docs\n")
b.WriteString("4) If there are additional blog posts this week, include them as short notes\n")
b.WriteString("5) Conclusion with 'food for thought' questions to encourage replies\n")
b.WriteString("Use concise headings and include links.\n\n")
var sources []string
b.WriteString("Blog posts:\n")
for _, item := range feedItems {
b.WriteString(fmt.Sprintf("- %s (%s)\n", item.Title, item.Link))
sources = append(sources, item.Link)
}
if len(redditItems) > 0 {
b.WriteString("\nReddit discussions:\n")
for _, post := range redditItems {
link := post.PermalinkURL
if link == "" {
link = post.URL
}
b.WriteString(fmt.Sprintf("- %s (%s)\n", post.Title, link))
sources = append(sources, link)
}
}
return b.String(), sources
}
+155
View File
@@ -0,0 +1,155 @@
package reddit
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"seo-optimizer/internal/logger"
)
const defaultBaseURL = "https://www.reddit.com"
// Post represents a simplified Reddit post.
type Post struct {
Title string
URL string
Permalink string
PermalinkURL string
SelfText string
Author string
Score int
NumComments int
CreatedUTC int64
Subreddit string
IsSelf bool
}
// Client fetches Reddit content via the public JSON endpoints.
type Client struct {
baseURL string
userAgent string
http *http.Client
logger *logger.Logger
}
// NewClient creates a Reddit client with sane defaults.
func NewClient(baseURL string, log *logger.Logger) *Client {
if log == nil {
log = logger.NewDefaultLogger()
}
if strings.TrimSpace(baseURL) == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
userAgent: "WPSideKick/1.0 (seo-optimizer)",
http: &http.Client{
Timeout: 20 * time.Second,
},
logger: log.WithComponent("reddit"),
}
}
// FetchPosts fetches posts from a subreddit using the specified listing.
// listing: top | hot | new
func (c *Client) FetchPosts(ctx context.Context, subreddit, listing, timeRange string, limit int) ([]Post, error) {
if strings.TrimSpace(subreddit) == "" {
return nil, fmt.Errorf("subreddit is required")
}
if limit <= 0 {
limit = 10
}
if listing == "" {
listing = "top"
}
path := fmt.Sprintf("/r/%s/%s.json", url.PathEscape(subreddit), listing)
params := url.Values{}
params.Set("limit", fmt.Sprintf("%d", limit))
if listing == "top" && timeRange != "" {
params.Set("t", timeRange)
}
endpoint := c.baseURL + path + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("reddit request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read reddit response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("reddit API error (status %d): %s", resp.StatusCode, string(body))
}
var listingResp redditListing
if err := json.Unmarshal(body, &listingResp); err != nil {
return nil, fmt.Errorf("failed to parse reddit response: %w", err)
}
posts := make([]Post, 0, len(listingResp.Data.Children))
for _, child := range listingResp.Data.Children {
data := child.Data
if data.Stickied || data.RemovedByCategory != "" {
continue
}
posts = append(posts, Post{
Title: strings.TrimSpace(data.Title),
URL: data.URL,
Permalink: data.Permalink,
PermalinkURL: c.baseURL + data.Permalink,
SelfText: data.SelfText,
Author: data.Author,
Score: data.Score,
NumComments: data.NumComments,
CreatedUTC: data.CreatedUTC,
Subreddit: data.Subreddit,
IsSelf: data.IsSelf,
})
}
c.logger.Info("Fetched Reddit posts",
"subreddit", subreddit,
"listing", listing,
"count", len(posts),
)
return posts, nil
}
type redditListing struct {
Data struct {
Children []struct {
Data struct {
Title string `json:"title"`
URL string `json:"url"`
Permalink string `json:"permalink"`
SelfText string `json:"selftext"`
Author string `json:"author"`
Score int `json:"score"`
NumComments int `json:"num_comments"`
CreatedUTC int64 `json:"created_utc"`
Subreddit string `json:"subreddit"`
IsSelf bool `json:"is_self"`
Stickied bool `json:"stickied"`
RemovedByCategory string `json:"removed_by_category"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
+97
View File
@@ -0,0 +1,97 @@
package scheduler
import (
"context"
"time"
"seo-optimizer/internal/ideas"
"seo-optimizer/internal/logger"
)
// IdeaScheduler handles periodic idea generation.
type IdeaScheduler struct {
service *ideas.Service
enableIdeas bool
enableOutreach bool
interval time.Duration
stopCh chan struct{}
logger *logger.Logger
}
func NewIdeaScheduler(service *ideas.Service, intervalHours float64, enableIdeas bool, enableOutreach bool, log *logger.Logger) *IdeaScheduler {
if log == nil {
log = logger.NewDefaultLogger()
}
if intervalHours <= 0 {
intervalHours = 24
}
return &IdeaScheduler{
service: service,
enableIdeas: enableIdeas,
enableOutreach: enableOutreach,
interval: time.Duration(intervalHours * float64(time.Hour)),
stopCh: make(chan struct{}),
logger: log.WithComponent("idea_scheduler"),
}
}
// Start begins the scheduler loop.
func (s *IdeaScheduler) Start(ctx context.Context) error {
s.logger.Info("Idea scheduler started", "interval", s.interval)
if err := s.runOnce(ctx); err != nil {
s.logger.Error("Initial idea generation failed", "error", err)
}
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.runOnce(ctx); err != nil {
s.logger.Error("Idea generation failed", "error", err)
}
case <-ctx.Done():
s.logger.Info("Idea scheduler stopped by context")
return ctx.Err()
case <-s.stopCh:
s.logger.Info("Idea scheduler stopped by signal")
return nil
}
}
}
// Stop stops the scheduler.
func (s *IdeaScheduler) Stop() {
close(s.stopCh)
}
func (s *IdeaScheduler) runOnce(ctx context.Context) error {
start := time.Now()
if s.enableIdeas {
ideas, err := s.service.GenerateDailyIdeas(ctx)
if err != nil {
return err
}
s.logger.Info("Idea generation completed",
"count", len(ideas),
"duration", time.Since(start),
)
}
if s.enableOutreach {
outreach, err := s.service.GenerateOutreachSuggestions(ctx)
if err != nil {
s.logger.Warn("Outreach generation failed", "error", err)
} else {
s.logger.Info("Outreach generation completed",
"count", len(outreach),
"duration", time.Since(start),
)
}
}
return nil
}
@@ -0,0 +1,79 @@
package scheduler
import (
"context"
"time"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/newsletter"
)
// NewsletterScheduler handles periodic newsletter generation.
type NewsletterScheduler struct {
service *newsletter.Service
interval time.Duration
stopCh chan struct{}
logger *logger.Logger
}
func NewNewsletterScheduler(service *newsletter.Service, intervalHours float64, log *logger.Logger) *NewsletterScheduler {
if log == nil {
log = logger.NewDefaultLogger()
}
if intervalHours <= 0 {
intervalHours = 168
}
return &NewsletterScheduler{
service: service,
interval: time.Duration(intervalHours * float64(time.Hour)),
stopCh: make(chan struct{}),
logger: log.WithComponent("newsletter_scheduler"),
}
}
// Start begins the scheduler loop.
func (s *NewsletterScheduler) Start(ctx context.Context) error {
s.logger.Info("Newsletter scheduler started", "interval", s.interval)
if err := s.runOnce(ctx); err != nil {
s.logger.Error("Initial newsletter generation failed", "error", err)
}
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.runOnce(ctx); err != nil {
s.logger.Error("Newsletter generation failed", "error", err)
}
case <-ctx.Done():
s.logger.Info("Newsletter scheduler stopped by context")
return ctx.Err()
case <-s.stopCh:
s.logger.Info("Newsletter scheduler stopped by signal")
return nil
}
}
}
// Stop stops the scheduler.
func (s *NewsletterScheduler) Stop() {
close(s.stopCh)
}
func (s *NewsletterScheduler) runOnce(ctx context.Context) error {
start := time.Now()
draft, err := s.service.GenerateWeeklyDraft(ctx)
if err != nil {
return err
}
s.logger.Info("Newsletter generation completed",
"id", draft.ID,
"duration", time.Since(start),
)
return nil
}
+58 -17
View File
@@ -22,7 +22,8 @@ import (
// Optimizer coordinates the entire SEO optimization workflow
type Optimizer struct {
wpClient *wordpress.Client
claudeClient *agent.ClaudeClient
llmClient agent.LLMClient
llmProvider string
contentBuilder *ContentBuilder
selector *wordpress.PostSelector
storage *storage.OptimizationStorage
@@ -42,7 +43,8 @@ func (e *SkipError) Error() string {
// NewOptimizer creates a new optimizer instance
func NewOptimizer(
wpClient *wordpress.Client,
claudeClient *agent.ClaudeClient,
llmClient agent.LLMClient,
llmProvider string,
store *storage.OptimizationStorage,
cfg *config.Config,
log *logger.Logger,
@@ -53,7 +55,8 @@ func NewOptimizer(
return &Optimizer{
wpClient: wpClient,
claudeClient: claudeClient,
llmClient: llmClient,
llmProvider: llmProvider,
contentBuilder: NewContentBuilder(log),
selector: wordpress.NewPostSelector(wpClient, log),
storage: store,
@@ -65,6 +68,11 @@ func NewOptimizer(
// OptimizeNextPost executes the full optimization cycle for the next eligible post
func (o *Optimizer) OptimizeNextPost(ctx context.Context, language string) error {
o.logger.Info("Starting optimization cycle", "language", language)
if o.hasPendingDrafts() {
reason := "pending drafts exist"
o.logger.Info("Skipping optimization", "reason", reason)
return &SkipError{Reason: reason}
}
// 1. Select next post
criteria := wordpress.SelectionCriteria{
@@ -112,6 +120,11 @@ func (o *Optimizer) OptimizeSpecificPost(ctx context.Context, postID int, langua
"post_id", postID,
"language", language,
)
if o.hasPendingDrafts() {
reason := "pending drafts exist"
o.logger.Info("Skipping optimization", "post_id", postID, "reason", reason)
return &SkipError{Reason: reason}
}
// 1. Fetch the specific post
post, err := o.wpClient.GetPost(ctx, postID, language)
@@ -164,7 +177,7 @@ func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, lang
startTime := time.Now()
// 2. Gather context for Claude
// 2. Gather context for the LLM
internalPosts, err := o.wpClient.GetInternalPosts(ctx, effectiveLanguage)
if err != nil {
return fmt.Errorf("failed to fetch internal posts: %w", err)
@@ -198,12 +211,12 @@ func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, lang
tagMap[tag.ID] = tag.Name
}
// 3. Call Claude for optimization
o.logger.Info("Calling Claude for optimization")
// 3. Call LLM for optimization
o.logger.Info("Calling LLM for optimization", "provider", o.llmProvider)
optimization, err := o.claudeClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap)
optimization, err := o.llmClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap)
if err != nil {
return fmt.Errorf("claude optimization failed: %w", err)
return fmt.Errorf("llm optimization failed: %w", err)
}
// Skip if the post is too recent and no modifications were required
@@ -231,7 +244,11 @@ func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, lang
}
// 5. Create draft revision in WordPress
o.logger.Info("Creating draft revision in WordPress")
if o.config.Runtime.DryRun {
o.logger.Info("Dry-run enabled, skipping WordPress draft creation")
} else {
o.logger.Info("Creating draft revision in WordPress")
}
updates := wordpress.PostUpdate{
Status: "draft",
@@ -252,15 +269,19 @@ func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, lang
},
}
draftPostID, err := o.wpClient.CreateDraftRevision(ctx, post.ID, updates)
if err != nil {
return fmt.Errorf("failed to create draft revision: %w", err)
}
draftPostID := 0
if !o.config.Runtime.DryRun {
var err error
draftPostID, err = o.wpClient.CreateDraftRevision(ctx, post.ID, updates)
if err != nil {
return fmt.Errorf("failed to create draft revision: %w", err)
}
o.logger.Info("Draft revision created",
"draft_post_id", draftPostID,
"original_post_id", post.ID,
)
o.logger.Info("Draft revision created",
"draft_post_id", draftPostID,
"original_post_id", post.ID,
)
}
// 6. Save optimization record to storage
record := &storage.OptimizationRecord{
@@ -389,6 +410,26 @@ func (o *Optimizer) SyncTranslationsEnabled() bool {
return o.config.Optimization.SyncTranslations
}
// IsDryRun returns whether the optimizer is running in dry-run mode.
func (o *Optimizer) IsDryRun() bool {
if o == nil || o.config == nil {
return false
}
return o.config.Runtime.DryRun
}
func (o *Optimizer) hasPendingDrafts() bool {
if o == nil || o.storage == nil {
return false
}
records, err := o.storage.ListPending()
if err != nil {
o.logger.Warn("Failed to list pending drafts", "error", err)
return false
}
return len(records) > 0
}
func (o *Optimizer) shouldSkipRecentNoChanges(post *wordpress.Post, optimization *models.OptimizationResponse) bool {
if o.config.Optimization.MinAgeDays <= 0 {
return false
+125
View File
@@ -0,0 +1,125 @@
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/google/uuid"
"seo-optimizer/internal/logger"
)
// AuditRecord captures a high-level action performed by the system or user.
type AuditRecord struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Actor string `json:"actor"` // cli, webui, scheduler, system
Action string `json:"action"` // optimize_requested, draft_applied, etc.
Summary string `json:"summary"` // human-readable summary
PostID int `json:"post_id,omitempty"` // affected original post
DraftPostID int `json:"draft_post_id,omitempty"`
JobID string `json:"job_id,omitempty"`
Language string `json:"language,omitempty"`
}
// AuditStorage manages file-based storage of audit records.
type AuditStorage struct {
basePath string
logger *logger.Logger
}
// NewAuditStorage creates a new audit storage instance.
func NewAuditStorage(basePath string, log *logger.Logger) *AuditStorage {
if log == nil {
log = logger.NewDefaultLogger()
}
return &AuditStorage{
basePath: basePath,
logger: log.WithComponent("audit_storage"),
}
}
// Initialize creates the audit storage directory.
func (s *AuditStorage) Initialize() error {
if err := os.MkdirAll(s.basePath, 0755); err != nil {
return fmt.Errorf("failed to create audit directory %s: %w", s.basePath, err)
}
return nil
}
// Save persists an audit record.
func (s *AuditStorage) Save(record *AuditRecord) error {
if record == nil {
return fmt.Errorf("audit record is nil")
}
if record.ID == "" {
record.ID = uuid.New().String()
}
if record.CreatedAt.IsZero() {
record.CreatedAt = time.Now()
}
if record.Actor == "" {
record.Actor = "system"
}
if record.Action == "" {
record.Action = "unknown"
}
filename := fmt.Sprintf("%d_%s.json", record.CreatedAt.UnixNano(), record.ID)
filePath := filepath.Join(s.basePath, filename)
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal audit record: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write audit record: %w", err)
}
return nil
}
// List returns audit records sorted by newest first. Limit <= 0 returns all.
func (s *AuditStorage) List(limit int) ([]*AuditRecord, error) {
files, err := os.ReadDir(s.basePath)
if err != nil {
if os.IsNotExist(err) {
return []*AuditRecord{}, nil
}
return nil, fmt.Errorf("failed to read audit directory: %w", err)
}
records := make([]*AuditRecord, 0, len(files))
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(s.basePath, file.Name())
data, err := os.ReadFile(filePath)
if err != nil {
s.logger.Warn("Failed to read audit record", "file", filePath, "error", err)
continue
}
var record AuditRecord
if err := json.Unmarshal(data, &record); err != nil {
s.logger.Warn("Failed to unmarshal audit record", "file", filePath, "error", err)
continue
}
records = append(records, &record)
}
sort.Slice(records, func(i, j int) bool {
return records[i].CreatedAt.After(records[j].CreatedAt)
})
if limit > 0 && len(records) > limit {
return records[:limit], nil
}
return records, nil
}
+228
View File
@@ -0,0 +1,228 @@
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"seo-optimizer/internal/logger"
"seo-optimizer/pkg/models"
)
// IdeaStorage manages file-based storage of post ideas.
type IdeaStorage struct {
basePath string
logger *logger.Logger
}
// NewIdeaStorage creates a new idea storage instance.
func NewIdeaStorage(basePath string, log *logger.Logger) *IdeaStorage {
if log == nil {
log = logger.NewDefaultLogger()
}
return &IdeaStorage{
basePath: basePath,
logger: log.WithComponent("idea_storage"),
}
}
// Initialize creates the storage directory structure.
func (s *IdeaStorage) Initialize() error {
dirs := []string{
s.basePath,
filepath.Join(s.basePath, "new"),
filepath.Join(s.basePath, "drafted"),
filepath.Join(s.basePath, "published"),
filepath.Join(s.basePath, "rejected"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
s.logger.Info("Idea storage initialized", "base_path", s.basePath)
return nil
}
// Save stores an idea record.
func (s *IdeaStorage) Save(idea *models.PostIdea) error {
if idea.Status == "" {
idea.Status = "new"
}
if idea.CreatedAt.IsZero() {
idea.CreatedAt = time.Now()
}
idea.UpdatedAt = time.Now()
dir := filepath.Join(s.basePath, idea.Status)
filename := fmt.Sprintf("%s_%d.json", idea.ID, idea.CreatedAt.Unix())
filePath := filepath.Join(dir, filename)
data, err := json.MarshalIndent(idea, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal idea: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
s.logger.Debug("Idea record saved",
"id", idea.ID,
"status", idea.Status,
"file", filePath,
)
return nil
}
// Get retrieves an idea by ID across all statuses.
func (s *IdeaStorage) Get(id string) (*models.PostIdea, error) {
statuses := []string{"new", "drafted", "published", "rejected"}
for _, status := range statuses {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
continue
}
for i := len(files) - 1; i >= 0; i-- {
file := files[i]
if file.IsDir() {
continue
}
fileID, _, err := parseIdeaFilename(file.Name())
if err != nil {
continue
}
if fileID == id {
filePath := filepath.Join(dir, file.Name())
return s.readIdea(filePath)
}
}
}
return nil, fmt.Errorf("idea not found: %s", id)
}
// List returns ideas for the specified status.
func (s *IdeaStorage) List(status string) ([]*models.PostIdea, error) {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []*models.PostIdea{}, nil
}
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var ideas []*models.PostIdea
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(dir, file.Name())
idea, err := s.readIdea(filePath)
if err != nil {
s.logger.Warn("Failed to read idea", "file", filePath, "error", err)
continue
}
ideas = append(ideas, idea)
}
sort.Slice(ideas, func(i, j int) bool {
return ideas[i].CreatedAt.After(ideas[j].CreatedAt)
})
return ideas, nil
}
// UpdateStatus moves an idea between status folders.
func (s *IdeaStorage) UpdateStatus(id, newStatus string) (*models.PostIdea, error) {
idea, err := s.Get(id)
if err != nil {
return nil, err
}
oldDir := filepath.Join(s.basePath, idea.Status)
oldFilename := fmt.Sprintf("%s_%d.json", idea.ID, idea.CreatedAt.Unix())
oldPath := filepath.Join(oldDir, oldFilename)
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to remove old idea file", "path", oldPath, "error", err)
}
idea.Status = newStatus
idea.UpdatedAt = time.Now()
if newStatus == "published" && idea.PublishedAt == nil {
now := time.Now()
idea.PublishedAt = &now
}
if err := s.Save(idea); err != nil {
return nil, err
}
return idea, nil
}
// Delete removes an idea file from disk.
func (s *IdeaStorage) Delete(id string) error {
idea, err := s.Get(id)
if err != nil {
return err
}
dir := filepath.Join(s.basePath, idea.Status)
filename := fmt.Sprintf("%s_%d.json", idea.ID, idea.CreatedAt.Unix())
path := filepath.Join(dir, filename)
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to delete idea: %w", err)
}
return nil
}
func (s *IdeaStorage) readIdea(path string) (*models.PostIdea, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var idea models.PostIdea
if err := json.Unmarshal(data, &idea); err != nil {
return nil, err
}
return &idea, nil
}
func parseIdeaFilename(name string) (string, int64, error) {
if filepath.Ext(name) != ".json" {
return "", 0, fmt.Errorf("invalid extension")
}
base := strings.TrimSuffix(name, ".json")
idx := strings.LastIndex(base, "_")
if idx <= 0 || idx >= len(base)-1 {
return "", 0, fmt.Errorf("invalid idea filename")
}
id := base[:idx]
tsPart := base[idx+1:]
ts, err := strconv.ParseInt(tsPart, 10, 64)
if err != nil {
return "", 0, fmt.Errorf("invalid timestamp")
}
return id, ts, nil
}
+197
View File
@@ -0,0 +1,197 @@
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"seo-optimizer/internal/logger"
"seo-optimizer/pkg/models"
)
// NewsletterStorage manages file-based storage of newsletter drafts.
type NewsletterStorage struct {
basePath string
logger *logger.Logger
}
// NewNewsletterStorage creates a new newsletter storage instance.
func NewNewsletterStorage(basePath string, log *logger.Logger) *NewsletterStorage {
if log == nil {
log = logger.NewDefaultLogger()
}
return &NewsletterStorage{
basePath: basePath,
logger: log.WithComponent("newsletter_storage"),
}
}
// Initialize creates the storage directory structure.
func (s *NewsletterStorage) Initialize() error {
dirs := []string{
s.basePath,
filepath.Join(s.basePath, "draft"),
filepath.Join(s.basePath, "sent"),
filepath.Join(s.basePath, "archived"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
s.logger.Info("Newsletter storage initialized", "base_path", s.basePath)
return nil
}
// Save stores a newsletter draft.
func (s *NewsletterStorage) Save(draft *models.NewsletterDraft) error {
if draft.Status == "" {
draft.Status = "draft"
}
if draft.CreatedAt.IsZero() {
draft.CreatedAt = time.Now()
}
draft.UpdatedAt = time.Now()
dir := filepath.Join(s.basePath, draft.Status)
filename := fmt.Sprintf("%s_%d.json", draft.ID, draft.CreatedAt.Unix())
filePath := filepath.Join(dir, filename)
data, err := json.MarshalIndent(draft, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal newsletter draft: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
s.logger.Debug("Newsletter draft saved",
"id", draft.ID,
"status", draft.Status,
"file", filePath,
)
return nil
}
// List returns drafts by status.
func (s *NewsletterStorage) List(status string) ([]*models.NewsletterDraft, error) {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []*models.NewsletterDraft{}, nil
}
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var drafts []*models.NewsletterDraft
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(dir, file.Name())
item, err := s.readDraft(filePath)
if err != nil {
s.logger.Warn("Failed to read draft", "file", filePath, "error", err)
continue
}
drafts = append(drafts, item)
}
sort.Slice(drafts, func(i, j int) bool {
return drafts[i].CreatedAt.After(drafts[j].CreatedAt)
})
return drafts, nil
}
// Delete removes a draft by ID.
func (s *NewsletterStorage) Delete(id string) error {
draft, err := s.Get(id)
if err != nil {
return err
}
dir := filepath.Join(s.basePath, draft.Status)
filename := fmt.Sprintf("%s_%d.json", draft.ID, draft.CreatedAt.Unix())
path := filepath.Join(dir, filename)
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to delete draft: %w", err)
}
return nil
}
// Get retrieves a draft by ID.
func (s *NewsletterStorage) Get(id string) (*models.NewsletterDraft, error) {
statuses := []string{"draft", "sent", "archived"}
for _, status := range statuses {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
continue
}
for i := len(files) - 1; i >= 0; i-- {
file := files[i]
if file.IsDir() {
continue
}
fileID, _, err := parseNewsletterFilename(file.Name())
if err != nil {
continue
}
if fileID == id {
filePath := filepath.Join(dir, file.Name())
return s.readDraft(filePath)
}
}
}
return nil, fmt.Errorf("draft not found: %s", id)
}
func (s *NewsletterStorage) readDraft(path string) (*models.NewsletterDraft, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var draft models.NewsletterDraft
if err := json.Unmarshal(data, &draft); err != nil {
return nil, err
}
return &draft, nil
}
func parseNewsletterFilename(name string) (string, int64, error) {
if filepath.Ext(name) != ".json" {
return "", 0, fmt.Errorf("invalid extension")
}
base := strings.TrimSuffix(name, ".json")
idx := strings.LastIndex(base, "_")
if idx <= 0 || idx >= len(base)-1 {
return "", 0, fmt.Errorf("invalid filename")
}
id := base[:idx]
tsPart := base[idx+1:]
ts, err := strconv.ParseInt(tsPart, 10, 64)
if err != nil {
return "", 0, fmt.Errorf("invalid timestamp")
}
return id, ts, nil
}
+34
View File
@@ -139,6 +139,40 @@ func (s *OptimizationStorage) ListApplied() ([]*OptimizationRecord, error) {
return s.listByStatus("applied")
}
// FindByDraftID returns the most recent record matching a draft post ID.
func (s *OptimizationStorage) FindByDraftID(draftPostID int) (*OptimizationRecord, error) {
statuses := []string{"pending", "applied", "rejected", "skipped"}
var newest *OptimizationRecord
for _, status := range statuses {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(dir, file.Name())
record, err := s.readRecord(filePath)
if err != nil {
s.logger.Warn("Failed to read record", "file", filePath, "error", err)
continue
}
if record.DraftPostID != draftPostID {
continue
}
if newest == nil || record.OptimizedAt.After(newest.OptimizedAt) {
newest = record
}
}
}
if newest == nil {
return nil, fmt.Errorf("optimization record not found for draft %d", draftPostID)
}
return newest, nil
}
// listByStatus returns all records with the specified status
func (s *OptimizationStorage) listByStatus(status string) ([]*OptimizationRecord, error) {
dir := filepath.Join(s.basePath, status)
+221
View File
@@ -0,0 +1,221 @@
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"seo-optimizer/internal/logger"
"seo-optimizer/pkg/models"
)
// OutreachStorage manages file-based storage of outreach suggestions.
type OutreachStorage struct {
basePath string
logger *logger.Logger
}
// NewOutreachStorage creates a new outreach storage instance.
func NewOutreachStorage(basePath string, log *logger.Logger) *OutreachStorage {
if log == nil {
log = logger.NewDefaultLogger()
}
return &OutreachStorage{
basePath: basePath,
logger: log.WithComponent("outreach_storage"),
}
}
// Initialize creates the storage directory structure.
func (s *OutreachStorage) Initialize() error {
dirs := []string{
s.basePath,
filepath.Join(s.basePath, "new"),
filepath.Join(s.basePath, "reviewed"),
filepath.Join(s.basePath, "dismissed"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
s.logger.Info("Outreach storage initialized", "base_path", s.basePath)
return nil
}
// Save stores a suggestion record.
func (s *OutreachStorage) Save(suggestion *models.OutreachSuggestion) error {
if suggestion.Status == "" {
suggestion.Status = "new"
}
if suggestion.CreatedAt.IsZero() {
suggestion.CreatedAt = time.Now()
}
suggestion.UpdatedAt = time.Now()
dir := filepath.Join(s.basePath, suggestion.Status)
filename := fmt.Sprintf("%s_%d.json", suggestion.ID, suggestion.CreatedAt.Unix())
filePath := filepath.Join(dir, filename)
data, err := json.MarshalIndent(suggestion, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal suggestion: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
s.logger.Debug("Outreach suggestion saved",
"id", suggestion.ID,
"status", suggestion.Status,
"file", filePath,
)
return nil
}
// List returns suggestions by status.
func (s *OutreachStorage) List(status string) ([]*models.OutreachSuggestion, error) {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []*models.OutreachSuggestion{}, nil
}
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var suggestions []*models.OutreachSuggestion
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(dir, file.Name())
item, err := s.readSuggestion(filePath)
if err != nil {
s.logger.Warn("Failed to read suggestion", "file", filePath, "error", err)
continue
}
suggestions = append(suggestions, item)
}
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].CreatedAt.After(suggestions[j].CreatedAt)
})
return suggestions, nil
}
// Delete removes a suggestion by ID.
func (s *OutreachStorage) Delete(id string) error {
suggestion, err := s.Get(id)
if err != nil {
return err
}
dir := filepath.Join(s.basePath, suggestion.Status)
filename := fmt.Sprintf("%s_%d.json", suggestion.ID, suggestion.CreatedAt.Unix())
path := filepath.Join(dir, filename)
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to delete suggestion: %w", err)
}
return nil
}
// Get retrieves a suggestion by ID.
func (s *OutreachStorage) Get(id string) (*models.OutreachSuggestion, error) {
statuses := []string{"new", "reviewed", "dismissed"}
for _, status := range statuses {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
continue
}
for i := len(files) - 1; i >= 0; i-- {
file := files[i]
if file.IsDir() {
continue
}
fileID, _, err := parseOutreachFilename(file.Name())
if err != nil {
continue
}
if fileID == id {
filePath := filepath.Join(dir, file.Name())
return s.readSuggestion(filePath)
}
}
}
return nil, fmt.Errorf("suggestion not found: %s", id)
}
// UpdateStatus moves a suggestion between status folders.
func (s *OutreachStorage) UpdateStatus(id, newStatus string) (*models.OutreachSuggestion, error) {
suggestion, err := s.Get(id)
if err != nil {
return nil, err
}
oldDir := filepath.Join(s.basePath, suggestion.Status)
oldFilename := fmt.Sprintf("%s_%d.json", suggestion.ID, suggestion.CreatedAt.Unix())
oldPath := filepath.Join(oldDir, oldFilename)
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to remove old suggestion file", "path", oldPath, "error", err)
}
suggestion.Status = newStatus
suggestion.UpdatedAt = time.Now()
if err := s.Save(suggestion); err != nil {
return nil, err
}
return suggestion, nil
}
func (s *OutreachStorage) readSuggestion(path string) (*models.OutreachSuggestion, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var suggestion models.OutreachSuggestion
if err := json.Unmarshal(data, &suggestion); err != nil {
return nil, err
}
return &suggestion, nil
}
func parseOutreachFilename(name string) (string, int64, error) {
if filepath.Ext(name) != ".json" {
return "", 0, fmt.Errorf("invalid extension")
}
base := strings.TrimSuffix(name, ".json")
idx := strings.LastIndex(base, "_")
if idx <= 0 || idx >= len(base)-1 {
return "", 0, fmt.Errorf("invalid filename")
}
id := base[:idx]
tsPart := base[idx+1:]
ts, err := strconv.ParseInt(tsPart, 10, 64)
if err != nil {
return "", 0, fmt.Errorf("invalid timestamp")
}
return id, ts, nil
}
+45
View File
@@ -224,6 +224,51 @@ func (c *Client) CreateDraftRevision(ctx context.Context, postID int, updates Po
return result.ID, nil
}
// CreateDraftPost creates a new draft post.
func (c *Client) CreateDraftPost(ctx context.Context, post PostCreate) (int, error) {
if strings.TrimSpace(post.Status) == "" {
post.Status = "draft"
}
endpoint := "/wp-json/wp/v2/posts"
var result Post
if err := c.post(ctx, endpoint, post, &result); err != nil {
return 0, fmt.Errorf("failed to create draft post: %w", err)
}
c.logger.Info("Draft post created",
"post_id", result.ID,
"title", post.Title,
"lang", post.Lang,
)
return result.ID, nil
}
// UpdatePostStatus updates the status for an existing post.
func (c *Client) UpdatePostStatus(ctx context.Context, postID int, status string) error {
if strings.TrimSpace(status) == "" {
return fmt.Errorf("status is required")
}
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", postID)
payload := map[string]interface{}{
"status": status,
}
var result Post
if err := c.post(ctx, endpoint, payload, &result); err != nil {
return fmt.Errorf("failed to update post status: %w", err)
}
c.logger.Info("Post status updated",
"post_id", postID,
"status", status,
)
return nil
}
// ApplyDraftToOriginal applies the draft changes to the original post and deletes the draft
func (c *Client) ApplyDraftToOriginal(ctx context.Context, draftPostID int) error {
// Get the draft post
+12
View File
@@ -110,6 +110,18 @@ type PostUpdate struct {
Meta map[string]interface{} `json:"meta,omitempty"`
}
// PostCreate represents the fields to create a new post.
type PostCreate struct {
Status string `json:"status"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Lang string `json:"lang,omitempty"`
Categories []int `json:"categories,omitempty"`
Tags []int `json:"tags,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
// ListParams represents parameters for listing posts
type ListParams struct {
// Lang is the Polylang language code (en, es, etc.)
+42
View File
@@ -0,0 +1,42 @@
package models
import "time"
// IdeaSource represents a source used to derive a post idea.
type IdeaSource struct {
Title string `json:"title"`
URL string `json:"url"`
Subreddit string `json:"subreddit,omitempty"`
Author string `json:"author,omitempty"`
Score int `json:"score,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// ArticleDraft represents a generated article draft in a specific language.
type ArticleDraft struct {
Language string `json:"language"`
Title string `json:"title"`
SEOTitle string `json:"seo_title"`
MetaDescription string `json:"meta_description"`
Excerpt string `json:"excerpt"`
ContentHTML string `json:"content_html"`
References []string `json:"references,omitempty"`
}
// PostIdea represents an SEO-focused post idea derived from sources.
type PostIdea struct {
ID string `json:"id"`
SEOTitle string `json:"seo_title"`
Summary string `json:"summary"`
PrimaryKW string `json:"primary_keyword"`
Sources []IdeaSource `json:"sources"`
References []string `json:"references,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DraftPostID int `json:"draft_post_id,omitempty"`
DraftPostIDEs int `json:"draft_post_id_es,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
ArticleEN *ArticleDraft `json:"article_en,omitempty"`
ArticleES *ArticleDraft `json:"article_es,omitempty"`
}
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
// NewsletterDraft represents a weekly newsletter draft.
type NewsletterDraft struct {
ID string `json:"id"`
Subject string `json:"subject"`
PreviewText string `json:"preview_text"`
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
Sources []string `json:"sources"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+3 -3
View File
@@ -1,6 +1,6 @@
package models
// OptimizationResponse represents the structured JSON response from Claude
// OptimizationResponse represents the structured JSON response from the LLM
// This is the complete output from the SEO optimization prompt
type OptimizationResponse struct {
SEOMeta SEOMeta `json:"seo_meta"`
@@ -64,8 +64,8 @@ type Validation struct {
// FactCheck represents validation of a technical claim
type FactCheck struct {
Claim string `json:"claim"`
Status string `json:"status"` // verified, outdated, uncertain
Source string `json:"source"` // URL to official docs if verified
Status string `json:"status"` // verified, outdated, uncertain
Source string `json:"source"` // URL to official docs if verified
RecommendedAction string `json:"recommended_action"` // keep, update, flag_for_review
UpdatedText string `json:"updated_text"` // if status=outdated
Reason string `json:"reason"` // explanation (in Spanish)
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
// OutreachSuggestion represents a suggested response linking to an existing post.
type OutreachSuggestion struct {
ID string `json:"id"`
RedditURL string `json:"reddit_url"`
RedditTitle string `json:"reddit_title"`
ArticleTitle string `json:"article_title"`
ArticleURL string `json:"article_url"`
SuggestedResponse string `json:"suggested_response"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}