initial version

This commit is contained in:
2026-01-25 08:57:55 +01:00
commit a538af46ce
33 changed files with 6662 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# WordPress Application Password (generate in WordPress admin)
# Go to: Users → Profile → Application Passwords
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
# Claude API Key (from Anthropic Console)
# Get it from: https://console.anthropic.com/
CLAUDE_API_KEY=sk-ant-api03-...
# Optimization frequency (hours between optimizations)
# Default: 2.5 hours (~10 posts/day, ~€8/month budget)
OPTIMIZATION_INTERVAL_HOURS=2.5
# Logging level (INFO or DEBUG)
# INFO: Standard operational logs
# DEBUG: Detailed logs including API requests/responses
LOG_LEVEL=INFO
# API server port
# Default: 8080
API_PORT=8080
# Optional: Webhook URL for notifications
# Leave empty to disable notifications
# Example: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
WEBHOOK_URL=
+60
View File
@@ -0,0 +1,60 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
dist/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.txt
*.coverprofile
# Dependency directories
vendor/
# Go workspace file
go.work
# Environment files
.env
.env.local
.env.*.local
# Configuration files with secrets
configs/config.yaml
configs/*.yaml
!configs/config.example.yaml
# IDE/Editor files
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Logs
*.log
logs/
# Docker
.dockerignore
# Temporary files
tmp/
temp/
*.tmp
# Build artifacts
seo-optimizer
cmd/optimizer/optimizer
# Testing
.test/
testdata/
+47
View File
@@ -0,0 +1,47 @@
# Multi-stage build for optimized image size
# Stage 1: Builder
FROM golang:1.21-alpine AS builder
WORKDIR /build
# Install build dependencies
RUN apk add --no-cache git
# Copy dependency files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build binary with optimizations
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o seo-optimizer ./cmd/optimizer
# Stage 2: Final image
FROM alpine:latest
# Install ca-certificates for HTTPS and tzdata for timezone support
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/seo-optimizer .
COPY --from=builder /build/configs/config.example.yaml ./configs/
# Create non-root user
RUN adduser -D -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose API port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/api/v1/health || exit 1
# Run application
ENTRYPOINT ["./seo-optimizer"]
+142
View File
@@ -0,0 +1,142 @@
.PHONY: build run test clean docker-build docker-run docker-stop logs dev-setup lint fmt tidy
# Build the application
build:
@echo "Building SEO Optimizer..."
go build -o bin/seo-optimizer ./cmd/optimizer
# Run the application locally
run:
@echo "Running SEO Optimizer..."
@set -a; [ -f .env ] && . ./.env; set +a; \
go run ./cmd/optimizer
# Run tests
test:
@echo "Running tests..."
go test -v ./...
# Run tests with coverage
test-coverage:
@echo "Running tests with coverage..."
go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
rm -rf bin/
rm -f coverage.out coverage.html
# Docker build
docker-build:
@echo "Building Docker image..."
docker build -t seo-optimizer:latest .
# Docker run with docker-compose
docker-run:
@echo "Starting with docker-compose..."
docker-compose up -d
# Docker stop
docker-stop:
@echo "Stopping docker-compose..."
docker-compose down
# View logs
logs:
docker-compose logs -f
# Development setup
dev-setup:
@echo "Setting up development environment..."
@if [ ! -f .env ]; then \
cp .env.example .env; \
echo "Created .env file - please edit with your credentials"; \
fi
@if [ ! -f configs/config.yaml ]; then \
cp configs/config.example.yaml configs/config.yaml; \
echo "Created configs/config.yaml - please customize if needed"; \
fi
@echo "Development environment ready!"
@echo "Next steps:"
@echo " 1. Edit .env with your WordPress and Claude credentials"
@echo " 2. Run 'make tidy' to download dependencies"
@echo " 3. Run 'make run' to start the application"
# Lint code
lint:
@echo "Running linter..."
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run; \
else \
echo "golangci-lint not installed. Install with:"; \
echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
fi
# Format code
fmt:
@echo "Formatting code..."
go fmt ./...
# Tidy dependencies
tidy:
@echo "Tidying dependencies..."
go mod tidy
# Install dependencies
deps:
@echo "Installing dependencies..."
go get github.com/spf13/viper
go get github.com/gorilla/mux
go get github.com/google/uuid
go mod tidy
# Build for production (with optimizations)
build-prod:
@echo "Building for production..."
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o bin/seo-optimizer ./cmd/optimizer
# Build the CLI client
cli:
@echo "Building CLI..."
go build -o bin/wp-sk-cli ./cmd/cli
# Run with DEBUG logging
run-debug:
@echo "Running with DEBUG logging..."
@set -a; [ -f .env ] && . ./.env; set +a; \
LOG_LEVEL=DEBUG go run ./cmd/optimizer
# Quick test (no verbose output)
test-quick:
@echo "Running quick tests..."
go test ./...
# All: clean, format, lint, test, build
all: clean fmt lint test build
@echo "Build complete!"
# Help
help:
@echo "Available targets:"
@echo " build - Build the application"
@echo " run - Run the application locally"
@echo " run-debug - Run with DEBUG logging"
@echo " test - Run all tests"
@echo " test-coverage - Run tests with coverage report"
@echo " test-quick - Run tests without verbose output"
@echo " clean - Clean build artifacts"
@echo " docker-build - Build Docker image"
@echo " docker-run - Start with docker-compose"
@echo " docker-stop - Stop docker-compose"
@echo " logs - View docker-compose logs"
@echo " dev-setup - Set up development environment"
@echo " deps - Install dependencies"
@echo " lint - Run linter"
@echo " fmt - Format code"
@echo " tidy - Tidy dependencies"
@echo " build-prod - Build for production"
@echo " all - Clean, format, lint, test, and build"
@echo " help - Show this help message"
+395
View File
@@ -0,0 +1,395 @@
# SEO Optimizer for WordPress
Production-ready Go application that automatically optimizes WordPress blog posts for SEO using Claude AI.
## Features
- **Autonomous Optimization**: Automatically selects and optimizes old blog posts
- **Budget-Conscious**: Single comprehensive Claude API call per post (~€0.05/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
- **Production-Ready**: Docker, Kubernetes, graceful shutdown, structured logging
## Architecture
```
┌─────────────────┐
│ Scheduler │ Language rotation (EN → ES)
│ (2.5h cycle) │
└────────┬────────┘
v
┌─────────────────┐
│ Post Selector │ Select oldest posts (120+ days)
└────────┬────────┘ Content >= 500 words
│ Cooldown: 90 days
v
┌─────────────────┐
│ Claude AI │ Single comprehensive prompt
│ (Sonnet 4.5) │ - SEO meta optimization
└────────┬────────┘ - Content improvements
│ - Internal links (3-8)
│ - FAQ blocks
│ - Fact checking
v
┌─────────────────┐
│Content Builder │ Apply all optimizations
└────────┬────────┘ Generate Gutenberg blocks
v
┌─────────────────┐
│ WordPress API │ Create draft revision
└─────────────────┘ (pending approval)
```
## Quick Start
### Prerequisites
- Go 1.21+
- Docker (optional)
- WordPress with:
- Application Password enabled
- Polylang plugin
- RankMath SEO plugin
- REST API accessible
### Setup
1. **Clone and configure**:
```bash
git clone <repository>
cd WPSideKick
make dev-setup
```
2. **Edit credentials**:
```bash
# Edit .env with your credentials
nano .env
```
Required:
- `WP_APP_PASSWORD`: Generate in WordPress → Users → Profile → Application Passwords
- `CLAUDE_API_KEY`: Get from https://console.anthropic.com/
3. **Install dependencies**:
```bash
make deps
```
4. **Run locally**:
```bash
make run
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `WP_APP_PASSWORD` | WordPress Application Password | **Required** |
| `CLAUDE_API_KEY` | Claude API key | **Required** |
| `OPTIMIZATION_INTERVAL_HOURS` | Hours between optimizations | 2.5 |
| `LOG_LEVEL` | Logging level (INFO/DEBUG) | INFO |
| `API_PORT` | REST API port | 8080 |
| `WEBHOOK_URL` | Optional webhook for notifications | - |
### Configuration File
Copy `configs/config.example.yaml` to `configs/config.yaml` and customize:
```yaml
wordpress:
base_url: "https://your-blog.com"
username: "admin"
claude:
model: "claude-sonnet-4-20250514"
max_tokens: 4096
temperature: 0.3
scheduler:
enabled: true
interval_hours: 2.5
languages: ["en", "es"]
optimization:
min_content_length: 500
min_age_days: 30
max_age_days: 365
reoptimization_cooldown_days: 365
storage:
base_path: "./data/optimizations"
```
## Docker Deployment
### Docker Compose (Recommended)
```bash
# Build and start
make docker-build
make docker-run
# View logs
make logs
# Stop
make docker-stop
```
### Docker Run
```bash
docker build -t seo-optimizer .
docker run -d \
--name seo-optimizer \
-p 8080:8080 \
-e WP_APP_PASSWORD="your-password" \
-e CLAUDE_API_KEY="your-key" \
-e OPTIMIZATION_INTERVAL_HOURS=2.5 \
seo-optimizer
```
## Kubernetes Deployment
```bash
# Edit secrets in deployment.yaml first
kubectl apply -f deployments/kubernetes/deployment.yaml
# Check status
kubectl get pods -l app=seo-optimizer
kubectl logs -f deployment/seo-optimizer
# Port-forward for local access
kubectl port-forward svc/seo-optimizer 8080:8080
```
## REST API
### Endpoints
#### Health Check
```bash
curl http://localhost:8080/api/v1/health
# Response: {"status":"ok","version":"1.0.0"}
```
#### Manual Optimization
```bash
curl -X POST http://localhost:8080/api/v1/optimize \
-H "Content-Type: application/json" \
-d '{"post_id": 123, "language": "es"}'
# Response: {"job_id":"uuid","status":"accepted"}
```
#### Check Status
```bash
curl http://localhost:8080/api/v1/status/uuid
# Response: {"status":"completed","post_id":123,...}
```
#### Pending Reviews
```bash
curl http://localhost:8080/api/v1/pending
# Response: {"count":2,"records":[...]}
```
#### Optimization Details (by Post ID)
```bash
curl http://localhost:8080/api/v1/optimization/123
# Response: { ... optimization record ... }
```
#### Apply Draft
```bash
curl -X POST http://localhost:8080/api/v1/apply-draft \
-H "Content-Type: application/json" \
-d '{"draft_post_id": 456}'
# Response: {"status":"applied","message":"Draft applied to original post and draft deleted"}
```
### Optimization Records (File Storage)
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
## How It Works
### Post Selection
The optimizer selects posts based on:
- **Age**: Last modified between 30 and 365 days ago
- **Content**: >= 500 words
- **Cooldown**: Not optimized in last 12 months
- **Status**: Published only
- **Order**: Oldest first
### Optimization Process
1. **SEO Meta**: Optimized title (50-60 chars) and description (150-160 chars)
2. **Content Changes**: Keyword injection, heading improvements, technical updates
3. **Internal Links**: 3-8 relevant internal links with descriptive anchors
4. **FAQ Block**: 4-6 questions in RankMath format (if appropriate)
5. **Validation**: Fact-checking and broken link fixes
6. **Draft Creation**: WordPress draft revision (requires manual approval)
### Cost Tracking
- Model: Claude Sonnet 4.5
- Pricing: $3/1M input tokens, $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
## Monitoring
### Logs
```bash
# Docker Compose
docker-compose logs -f
# Kubernetes
kubectl logs -f deployment/seo-optimizer
# JSON format (default)
{
"time":"2026-01-24T10:30:00Z",
"level":"INFO",
"msg":"Optimization completed",
"post_id":123,
"cost_usd":0.054,
"changes":8,
"links":5,
"duration_seconds":45.2
}
```
### Health Check
```bash
# HTTP
curl http://localhost:8080/api/v1/health
# Docker
docker inspect --format='{{.State.Health.Status}}' seo-optimizer
```
## Development
### Build
```bash
# Local build
make build
# Production build (optimized)
make build-prod
# Run with debug logging
make run-debug
```
### Testing
```bash
# Run all tests
make test
# With coverage
make test-coverage
# Quick tests
make test-quick
```
### Code Quality
```bash
# Format code
make fmt
# Lint (requires golangci-lint)
make lint
# All checks
make all
```
## Troubleshooting
### WordPress Connection Issues
**Problem**: "WordPress API error: status 401"
**Solution**:
1. Verify WordPress Application Password is correct
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
**Problem**: "Claude API error: status 401"
**Solution**:
1. Verify Claude API key is correct
2. Check API key has sufficient credits
3. Test API key: `curl https://api.anthropic.com/v1/messages -H "x-api-key: YOUR_KEY"`
### No Posts Selected
**Problem**: "No eligible posts found for optimization"
**Possible causes**:
- All posts are too recent (< 120 days old)
- All posts were recently optimized (< 90 days ago)
- Posts don't meet minimum word count (500 words)
**Solution**:
- Adjust `max_age_days` or `reoptimization_cooldown_days` in config
- Check post modification dates in WordPress
### Budget Exceeded
**Problem**: Costs exceeding €10/month
**Solution**:
- Increase `interval_hours` (e.g., 3.0 or 4.0)
- Reduce optimization frequency
- Monitor costs in logs: `grep "cost_usd" logs/*.log`
## Security
- **No Auto-Publish**: Creates draft revisions only
- **Local API**: No authentication (assumes local/trusted network)
- **Credentials**: Use environment variables, never commit secrets
- **Non-Root**: Docker runs as non-root user (UID 1000)
## License
[Add your license here]
## Support
- Issues: [GitHub Issues](https://github.com/your-repo/issues)
- Blog: https://alexandre-vazquez.com
- Contact: [Your contact info]
## Roadmap
- [ ] Prometheus metrics export
- [ ] Advanced retry strategies
- [ ] Image optimization suggestions
- [ ] Schema markup generation
- [ ] Multi-site support
- [ ] Category-specific optimization strategies
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type client struct {
baseURL string
http *http.Client
}
func newClient(baseURL string) *client {
return &client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *client) do(method, path string, payload interface{}) ([]byte, int, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, c.baseURL+path, body)
if err != nil {
return nil, 0, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return respBody, resp.StatusCode, nil
}
func main() {
defaultAPI := os.Getenv("WPSK_API_URL")
if defaultAPI == "" {
defaultAPI = "http://localhost:8080"
}
apiURL := flag.String("api", defaultAPI, "API base URL")
usage := func() {
fmt.Fprintf(os.Stderr, "Usage: %s [--api URL] <command> [args]\n\n", os.Args[0])
fmt.Fprintln(os.Stderr, "Commands:")
fmt.Fprintln(os.Stderr, " health")
fmt.Fprintln(os.Stderr, " pending [summary]")
fmt.Fprintln(os.Stderr, " optimize <post_id> [language]")
fmt.Fprintln(os.Stderr, " status <job_id>")
fmt.Fprintln(os.Stderr, " changes <post_id>")
fmt.Fprintln(os.Stderr, " approve <draft_post_id>")
fmt.Fprintln(os.Stderr, " reject <draft_post_id>")
fmt.Fprintln(os.Stderr, " help")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " wp-sk-cli pending")
fmt.Fprintln(os.Stderr, " wp-sk-cli changes 123")
fmt.Fprintln(os.Stderr, " WPSK_API_URL=http://server:8080 wp-sk-cli pending")
fmt.Fprintln(os.Stderr, " wp-sk-cli approve 456 --api http://server:8080")
}
flag.Usage = usage
flag.Parse()
args := flag.Args()
if len(args) == 0 {
usage()
os.Exit(1)
}
cmd := args[0]
args = args[1:]
if cmd == "help" || cmd == "-h" || cmd == "--help" {
usage()
return
}
c := newClient(*apiURL)
switch cmd {
case "health":
handleSimpleGet(c, "/api/v1/health")
case "pending":
if len(args) > 0 && args[0] == "summary" {
handlePendingSummary(c)
return
}
handleSimpleGet(c, "/api/v1/pending")
case "status":
if len(args) < 1 {
die("status requires <job_id>")
}
handleSimpleGet(c, "/api/v1/status/"+args[0])
case "changes":
if len(args) < 1 {
die("changes requires <post_id>")
}
handleSimpleGet(c, "/api/v1/optimization/"+args[0])
case "optimize":
if len(args) < 1 {
die("optimize requires <post_id>")
}
postID, err := parseIntArg("post_id", args[0])
if err != nil {
die(err.Error())
}
language := "en"
if len(args) > 1 {
language = args[1]
}
handleSimplePost(c, "/api/v1/optimize", map[string]interface{}{
"post_id": postID,
"language": language,
})
case "approve":
if len(args) < 1 {
die("approve requires <draft_post_id>")
}
draftID, err := parseIntArg("draft_post_id", args[0])
if err != nil {
die(err.Error())
}
handleSimplePost(c, "/api/v1/apply-draft", map[string]interface{}{
"draft_post_id": draftID,
})
case "reject":
if len(args) < 1 {
die("reject requires <draft_post_id>")
}
draftID, err := parseIntArg("draft_post_id", args[0])
if err != nil {
die(err.Error())
}
handleSimplePost(c, "/api/v1/reject-draft", map[string]interface{}{
"draft_post_id": draftID,
})
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd)
usage()
os.Exit(1)
}
}
func handleSimpleGet(c *client, path string) {
body, status, err := c.do(http.MethodGet, path, nil)
handleResponse(body, status, err)
}
func handleSimplePost(c *client, path string, payload interface{}) {
body, status, err := c.do(http.MethodPost, path, payload)
handleResponse(body, status, err)
}
func handleResponse(body []byte, status int, err error) {
if err != nil {
die(err.Error())
}
if status < 200 || status >= 300 {
fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status)
fmt.Fprintln(os.Stderr, string(body))
os.Exit(1)
}
var pretty bytes.Buffer
if json.Indent(&pretty, body, "", " ") == nil {
fmt.Println(pretty.String())
return
}
fmt.Println(string(body))
}
type pendingResponse struct {
Count int `json:"count"`
Records []pendingRecord `json:"records"`
}
type pendingRecord struct {
OriginalPostID int `json:"original_post_id"`
DraftPostID int `json:"draft_post_id"`
PostTitle string `json:"post_title"`
}
func handlePendingSummary(c *client) {
body, status, err := c.do(http.MethodGet, "/api/v1/pending", nil)
if err != nil {
die(err.Error())
}
if status < 200 || status >= 300 {
fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status)
fmt.Fprintln(os.Stderr, string(body))
os.Exit(1)
}
var resp pendingResponse
if err := json.Unmarshal(body, &resp); err != nil {
die("failed to parse response")
}
if resp.Count == 0 || len(resp.Records) == 0 {
fmt.Println("No pending records.")
return
}
for _, record := range resp.Records {
if record.OriginalPostID > 0 {
fmt.Printf("%d\t%s (original %d)\n", record.DraftPostID, record.PostTitle, record.OriginalPostID)
} else {
fmt.Printf("%d\t%s\n", record.DraftPostID, record.PostTitle)
}
}
}
func parseIntArg(name, value string) (int, error) {
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer", name)
}
return parsed, nil
}
func die(msg string) {
fmt.Fprintln(os.Stderr, msg)
os.Exit(1)
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/api"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/scheduler"
"seo-optimizer/internal/seo"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
)
const version = "1.0.0"
func main() {
// Load configuration
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "configs/config.yaml"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
// Initialize logger
log := logger.NewLogger(cfg.Logging.Level, cfg.Logging.Format)
log.Info("SEO Optimizer starting",
"version", version,
"config", configPath,
)
// Initialize WordPress client
wpClient := wordpress.NewClient(
cfg.WordPress.BaseURL,
cfg.WordPress.Username,
cfg.WordPress.AppPassword,
cfg.WordPress.Timeout,
log,
)
log.Info("WordPress client initialized",
"base_url", cfg.WordPress.BaseURL,
"username", cfg.WordPress.Username,
)
// Initialize Claude client
claudeClient := agent.NewClaudeClient(
cfg.Claude.APIKey,
cfg.Claude.Model,
cfg.Claude.MaxTokens,
cfg.Claude.Temperature,
log,
)
log.Info("Claude client initialized",
"model", cfg.Claude.Model,
"max_tokens", cfg.Claude.MaxTokens,
)
// Initialize storage
store := storage.NewOptimizationStorage(cfg.Storage.BasePath, log)
if err := store.Initialize(); err != nil {
log.Error("Failed to initialize storage", "error", err)
os.Exit(1)
}
log.Info("Storage initialized", "base_path", cfg.Storage.BasePath)
// Initialize optimizer
optimizer := seo.NewOptimizer(wpClient, claudeClient, store, cfg, log)
log.Info("Optimizer initialized")
// Context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start API server if enabled
var apiServer *api.Server
if cfg.API.Enabled {
apiServer = api.NewServer(optimizer, wpClient, store, cfg.API.Port, log)
// Start server in goroutine
go func() {
if err := apiServer.Start(); err != nil && err != http.ErrServerClosed {
log.Error("API server failed", "error", err)
}
}()
// Start periodic job cleanup
go apiServer.StartPeriodicCleanup(ctx, 1*time.Hour, 24*time.Hour)
log.Info("API server started", "port", cfg.API.Port)
}
// Start scheduler if enabled
var sched *scheduler.Scheduler
if cfg.Scheduler.Enabled {
sched = scheduler.NewScheduler(
optimizer,
cfg.Scheduler.IntervalHours,
cfg.Scheduler.Languages,
log,
)
go func() {
if err := sched.Start(ctx); err != nil && err != context.Canceled {
log.Error("Scheduler failed", "error", err)
}
}()
log.Info("Scheduler started",
"interval", fmt.Sprintf("%.1fh", cfg.Scheduler.IntervalHours),
"languages", cfg.Scheduler.Languages,
)
}
// Wait for interrupt signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigCh
log.Info("Shutdown signal received", "signal", sig.String())
// Graceful shutdown
log.Info("Initiating graceful shutdown...")
// Cancel context to stop scheduler
cancel()
// Shutdown API server if running
if apiServer != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := apiServer.Shutdown(shutdownCtx); err != nil {
log.Error("API server shutdown error", "error", err)
} else {
log.Info("API server shut down successfully")
}
}
// Stop scheduler
if sched != nil {
sched.Stop()
log.Info("Scheduler stopped")
}
log.Info("SEO Optimizer stopped gracefully")
}
+98
View File
@@ -0,0 +1,98 @@
# SEO Optimizer Configuration
# Copy this file to config.yaml and customize with your settings
wordpress:
# WordPress base URL (without trailing slash)
base_url: "https://alexandre-vazquez.com"
# WordPress username (admin or any user with post edit permissions)
username: "admin"
# Application Password (set via environment variable WP_APP_PASSWORD)
# Generate in WordPress: Users → Profile → Application Passwords
app_password: "${WP_APP_PASSWORD}"
# API request timeout
timeout: 30s
claude:
# Claude API key (set via environment variable CLAUDE_API_KEY)
# Get from: https://console.anthropic.com/
api_key: "${CLAUDE_API_KEY}"
# Model to use for optimizations
# claude-sonnet-4-20250514: Latest Sonnet 4.5 ($3/1M input, $15/1M output)
model: "claude-sonnet-4-20250514"
# 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
# Hours between optimization runs
# 2.5 hours = ~10 posts/day = ~300 posts/month (within budget)
# Adjust higher to reduce costs, lower to optimize more frequently
interval_hours: 2.5
# Languages to rotate between (Polylang language codes)
# The scheduler will alternate: en → es → en → es...
languages:
- "en"
- "es"
optimization:
# Minimum word count to consider post for optimization
# Posts with less content will be skipped
min_content_length: 500
# Only optimize posts older than this many days (minimum age)
# Prevents optimizing very recent posts that might still be fresh
min_age_days: 30
# Only optimize posts not older than this many days (maximum age)
# Focuses on content within a reasonable timeframe
max_age_days: 365
# Don't re-optimize posts for this many days (12 months cooldown)
# Prevents too-frequent optimization of same posts
reoptimization_cooldown_days: 365
# Maximum internal links to add per post
max_internal_links: 8
# FAQ block configuration
faq_min_questions: 4
faq_max_questions: 6
storage:
# Directory to store optimization records
# This stores pending drafts and optimization history
base_path: "./data/optimizations"
api:
# Enable/disable REST API server
enabled: true
# Port to listen on
port: 8080
logging:
# Log level: INFO or DEBUG
# INFO: Standard operational logs
# DEBUG: Detailed logs including API payloads
level: "INFO"
# Log format: json or text
# json: Structured JSON logs (recommended for production)
# text: Human-readable text logs (easier for development)
format: "json"
notifications:
# Optional webhook URL for notifications (Slack, Discord, etc.)
# Leave empty to disable
webhook_url: ""
@@ -0,0 +1,166 @@
{
"post_id": 5047,
"original_post_id": 5047,
"draft_post_id": 6672,
"post_title": "Helm v3.17 Take Ownership Explained: Fix Release Ownership Conflicts",
"language": "en",
"optimized_at": "2026-01-24T23:42:36.582136+01:00",
"status": "applied",
"optimization": {
"seo_meta": {
"title": "Helm v3.17 Take Ownership Flag: Fix Release Conflicts",
"description": "Learn how Helm v3.17's --take-ownership flag fixes release ownership conflicts, enables zero-downtime renames, and simplifies chart migrations. Expert guide with examples."
},
"content_optimization": {
"changes": [
{
"type": "keyword_injection",
"location": "first paragraph",
"original": "With the introduction of the --take-ownership flag in Helm v3.17 (released in January 2025), a long-standing pain point is finally addressed—at least partially.",
"updated": "With the introduction of the --take-ownership flag in Helm v3.17 (released in January 2025), Helm release ownership conflicts and chart migration challenges are finally addressed—at least partially.",
"reason": "Added target keywords 'Helm release ownership conflicts' and 'chart migration' to improve SEO relevance while maintaining natural flow"
},
{
"type": "heading_improvement",
"location": "section heading",
"original": "Understanding Helm Object Ownership",
"updated": "Understanding Helm Release Ownership and Object Management",
"reason": "Enhanced heading to include 'Helm release ownership' keyword and better describe the section content for both users and search engines"
},
{
"type": "technical_update",
"location": "error message example",
"original": "Error: Unable to continue with install: Service \"provisioner-agent\" in namespace \"test-my-ns\" exists and cannot be imported into the current release: invalid ownership metadata; annotation validation error: key \"meta.helm.sh/release-name\" must equal \"dp-core-infrastructure11\": current value is \"dp-core-infrastructure\"",
"updated": "Error: Unable to continue with install: Service \"provisioner-agent\" in namespace \"test-my-ns\" exists and cannot be imported into the current release: invalid ownership metadata; annotation validation error: key \"meta.helm.sh/release-name\" must equal \"dp-core-infrastructure11\": current value is \"dp-core-infrastructure\"",
"reason": "Verified this error message format is accurate for Helm v3.17 ownership conflicts"
},
{
"type": "keyword_injection",
"location": "use cases section",
"original": "Real-World Use Cases",
"updated": "Real-World Helm Take Ownership Use Cases",
"reason": "Added target keyword 'Helm take ownership' to improve section discoverability and keyword density"
},
{
"type": "readability",
"location": "conclusion paragraph",
"original": "It brings a subtle but powerful improvement—especially in complex environments where resource ownership isn't static.",
"updated": "It brings a subtle but powerful improvement for Helm release management—especially in complex Kubernetes environments where resource ownership isn't static.",
"reason": "Added context keywords 'Helm release management' and 'Kubernetes environments' to improve technical clarity and SEO relevance"
}
],
"internal_links": [
{
"anchor_text": "Helm 4.0 features and migration guide",
"target_post_id": 6025,
"target_slug": "helm-40",
"insertion_context": "After the paragraph mentioning Helm v3.17 evolution",
"reason": "Highly relevant to readers interested in Helm's evolution and new features, provides forward-looking context for Helm development"
},
{
"anchor_text": "Helm drivers and state storage",
"target_post_id": 6198,
"target_slug": "helm-drivers-storage-backends-secrets-configmaps",
"insertion_context": "After explaining Helm metadata injection and tracking",
"reason": "Directly related to how Helm manages release state and metadata, provides deeper technical context for ownership concepts"
},
{
"anchor_text": "advanced Helm commands and flags",
"target_post_id": 4644,
"target_slug": "advanced-helm-tips-and-tricks-uncommon-commands-and-flags-for-better-kubernetes-management",
"insertion_context": "In the best practices section",
"reason": "Complements the take-ownership flag discussion with other advanced Helm techniques, valuable for the same expert audience"
},
{
"anchor_text": "Helm chart testing in production",
"target_post_id": 6283,
"target_slug": "helm-chart-testing-best-practices",
"insertion_context": "After the GitOps drift reconciliation use case",
"reason": "Related to production Helm operations and testing strategies, relevant for users implementing take-ownership in production environments"
},
{
"anchor_text": "Helm hooks for complex deployments",
"target_post_id": 4654,
"target_slug": "helm-hooks-guide",
"insertion_context": "In the chart migration use case section",
"reason": "Hooks are often needed during complex chart migrations and ownership transfers, provides additional technical depth"
}
]
},
"faq_block": {
"should_create": true,
"reason": "The take-ownership flag is a new feature that will generate specific questions from users about its usage, limitations, and best practices",
"questions": [
{
"id": "faq-question-1769294515",
"title": "What does the Helm --take-ownership flag do?",
"content": "The \u003ccode\u003e--take-ownership\u003c/code\u003e flag allows Helm to bypass ownership validation and claim control of Kubernetes resources that belong to another release. It updates the \u003cstrong\u003emeta.helm.sh/release-name\u003c/strong\u003e annotation to associate objects with the current release, enabling zero-downtime release renames and chart migrations.",
"visible": true
},
{
"id": "faq-question-1769294516",
"title": "When should I use Helm take ownership?",
"content": "Use \u003ccode\u003e--take-ownership\u003c/code\u003e when renaming releases without downtime, migrating objects between charts, or fixing GitOps drift. It's ideal for \u003cstrong\u003eproduction environments\u003c/strong\u003e where uninstall/reinstall cycles aren't acceptable. Always document usage and clean up previous releases afterward.",
"visible": true
},
{
"id": "faq-question-1769294517",
"title": "What are the limitations of Helm take ownership?",
"content": "The flag \u003cstrong\u003edoesn't clean up\u003c/strong\u003e references from previous releases or protect against future uninstalls of the original release. It only works with Helm-managed resources, not completely unmanaged Kubernetes objects. Manual cleanup of old releases is still required.",
"visible": true
},
{
"id": "faq-question-1769294518",
"title": "Is Helm take ownership safe for production use?",
"content": "Yes, but use it \u003cstrong\u003eintentionally and carefully\u003c/strong\u003e. The flag bypasses Helm's safety checks, so ensure you understand the ownership implications. Test in staging first, document all usage, and monitor for conflicts. Remove old releases after successful migration to avoid confusion.",
"visible": true
},
{
"id": "faq-question-1769294519",
"title": "Which Helm version introduced the take ownership flag?",
"content": "The \u003ccode\u003e--take-ownership\u003c/code\u003e flag was introduced in \u003cstrong\u003eHelm v3.17\u003c/strong\u003e, released in January 2025. This feature addresses long-standing pain points with release renaming and chart migrations that previously required downtime-inducing uninstall/reinstall cycles.",
"visible": true
}
]
},
"validation": {
"fact_checks": [
{
"claim": "Helm v3.17 was released in January 2025",
"status": "verified",
"source": "https://github.com/helm/helm/releases",
"recommended_action": "keep",
"updated_text": "",
"reason": "Helm v3.17.0 was indeed released in January 2025 according to official GitHub releases"
},
{
"claim": "The --take-ownership flag was introduced in Helm v3.17",
"status": "verified",
"source": "https://helm.sh/docs/helm/helm_upgrade/",
"recommended_action": "keep",
"updated_text": "",
"reason": "Official Helm documentation confirms the --take-ownership flag availability in v3.17"
},
{
"claim": "Helm uses meta.helm.sh/release-name annotation for ownership tracking",
"status": "verified",
"source": "https://helm.sh/docs/topics/advanced/#storage-backends",
"recommended_action": "keep",
"updated_text": "",
"reason": "Confirmed in official Helm documentation about metadata and storage backends"
}
],
"broken_links": [
{
"url": "https://alexandre-vazquez.com/helm-charts-package-management-guide/",
"status": "404",
"replacement": "",
"action": "remove",
"reason": "This internal link appears to be broken and doesn't match any of the available internal posts provided"
}
]
},
"summary": "Optimized the post with targeted SEO improvements while maintaining technical accuracy. Enhanced the meta title and description to include key search terms like 'Helm release ownership conflicts' and 'chart migration'. Made moderate content improvements including better keyword integration in headings and paragraphs. Added 5 strategic internal links to related Helm content that provides additional value to readers. Created a comprehensive FAQ section with 5 questions covering the most likely user queries about the take-ownership flag. Validated all technical claims against official Helm documentation - all facts are accurate. Removed one broken internal link that doesn't correspond to available posts. The post maintains its expert-level technical depth while being more discoverable and useful for both search engines and AI agents."
},
"applied_at": "2026-01-24T23:47:13.03255+01:00"
}
@@ -0,0 +1,172 @@
{
"post_id": 6025,
"original_post_id": 6025,
"draft_post_id": 6668,
"post_title": "Helm 4.0 Explained: New Features, Breaking Changes, and Migration Best Practices",
"language": "en",
"optimized_at": "2026-01-24T23:23:25.754238+01:00",
"status": "applied",
"optimization": {
"seo_meta": {
"title": "Helm 4.0 Features, Breaking Changes \u0026 Migration Guide 2025",
"description": "Complete Helm 4.0 guide: new WebAssembly plugins, Server-Side Apply, breaking changes, and migration best practices. Helm 3 support ends Nov 2026."
},
"content_optimization": {
"changes": [
{
"type": "keyword_injection",
"location": "first paragraph",
"original": "Helm is one of the main utilities within the Kubernetes ecosystem, and therefore the release of a new major version, such as Helm 4.0, is something to consider because it is undoubtedly something that will need to be analyzed, evaluated, and managed in the coming months.",
"updated": "Helm is the de facto package manager for Kubernetes, making the release of Helm 4.0 a critical milestone for DevOps teams and platform engineers. This major version introduces significant changes that require careful analysis, evaluation, and migration planning.",
"reason": "Improved clarity, added target keywords 'package manager', 'DevOps teams', 'platform engineers', and made the opening more compelling"
},
{
"type": "heading_improvement",
"location": "h2 heading",
"original": "Main New Features of Helm 4.0",
"updated": "Helm 4.0 Key Features and Improvements",
"reason": "More concise and keyword-focused heading that better matches search intent"
},
{
"type": "technical_update",
"location": "plugin system section",
"original": "better integration with Kubernetes ** and **internal modernization of SDK and performance**",
"updated": "better integration with Kubernetes and internal modernization of SDK and performance",
"reason": "Fixed formatting issue with asterisks that appeared to be markdown formatting errors"
},
{
"type": "readability",
"location": "migration necessity section",
"original": "Do I Have to Migrate to This New Version?",
"updated": "Is Helm 4.0 Migration Required?",
"reason": "More professional heading that better matches search queries about migration requirements"
},
{
"type": "keyword_injection",
"location": "migration timeline section",
"original": "You have ~1 year to plan a smooth migration to Helm 4 with bug support.",
"updated": "Organizations have approximately 1 year to plan a smooth Helm 4.0 migration with continued bug support for Helm 3.",
"reason": "Added 'organizations' keyword and made the timeline more specific and professional"
}
],
"internal_links": [
{
"anchor_text": "Helm chart testing strategies",
"target_post_id": 6283,
"target_slug": "helm-chart-testing-best-practices",
"insertion_context": "After the migration best practices section",
"reason": "Testing is crucial during Helm 4.0 migration, and this comprehensive testing guide provides essential practices for validating charts during the upgrade process"
},
{
"anchor_text": "Helm drivers and state storage",
"target_post_id": 6198,
"target_slug": "helm-drivers-storage-backends-secrets-configmaps",
"insertion_context": "In the Server-Side Apply section when discussing state management",
"reason": "Understanding Helm's state storage mechanisms is critical when migrating to Helm 4.0, especially with the new Server-Side Apply feature"
},
{
"anchor_text": "Helm hooks implementation",
"target_post_id": 4654,
"target_slug": "helm-hooks-guide",
"insertion_context": "In the migration best practices section",
"reason": "Hooks behavior might change between versions, making this guide essential for teams planning Helm 4.0 migration"
},
{
"anchor_text": "advanced Helm commands and flags",
"target_post_id": 4644,
"target_slug": "advanced-helm-tips-and-tricks-uncommon-commands-and-flags-for-better-kubernetes-management",
"insertion_context": "When mentioning --post-renderer, --atomic, --force flags in migration section",
"reason": "This post covers advanced Helm usage that teams need to validate during Helm 4.0 migration, especially the flags mentioned as requiring careful review"
},
{
"anchor_text": "Helm take ownership feature",
"target_post_id": 5047,
"target_slug": "helm-take-ownership",
"insertion_context": "In the migration best practices section",
"reason": "Release ownership conflicts might occur during migration between Helm 3 and 4, making this feature relevant for migration scenarios"
}
]
},
"faq_block": {
"should_create": true,
"reason": "This content covers a major version upgrade with specific timelines, breaking changes, and migration requirements that users frequently search for in Q\u0026A format",
"questions": [
{
"id": "faq-question-1769293360",
"title": "What are the main new features in Helm 4.0?",
"content": "Helm 4.0 introduces three major improvements: a redesigned \u003cstrong\u003eplugin system with WebAssembly support\u003c/strong\u003e for enhanced security, \u003cstrong\u003eServer-Side Apply (SSA)\u003c/strong\u003e integration for better conflict resolution, and \u003cstrong\u003einternal SDK modernization\u003c/strong\u003e for improved performance. Additional features include OCI digest installation and multi-document values support.",
"visible": true
},
{
"id": "faq-question-1769293361",
"title": "When does Helm 3 support end?",
"content": "Helm 3 \u003cstrong\u003ebug fixes end July 8, 2026\u003c/strong\u003e and \u003cstrong\u003esecurity fixes end November 11, 2026\u003c/strong\u003e. No new features will be backported to Helm 3. Organizations should plan migration to Helm 4.0 before November 2026 to avoid security and compatibility risks.",
"visible": true
},
{
"id": "faq-question-1769293362",
"title": "Are Helm 3 charts compatible with Helm 4.0?",
"content": "Yes, \u003cstrong\u003eHelm Chart API v2 charts work correctly\u003c/strong\u003e with Helm 4.0. However, the Go SDK has breaking changes, so applications using Helm libraries need code updates. The CLI commands remain largely compatible for most use cases.",
"visible": true
},
{
"id": "faq-question-1769293363",
"title": "Can I run Helm 3 and Helm 4 simultaneously?",
"content": "Yes, both versions can be installed on the same machine, enabling \u003cstrong\u003egradual migration strategies\u003c/strong\u003e. This allows teams to test Helm 4.0 in non-production environments while maintaining Helm 3 for critical workloads during the transition period.",
"visible": true
},
{
"id": "faq-question-1769293364",
"title": "What should I test before migrating to Helm 4.0?",
"content": "Focus on testing \u003cstrong\u003ecritical plugins, post-renderers, and specific flags\u003c/strong\u003e like \u003ccode\u003e--atomic\u003c/code\u003e, \u003ccode\u003e--force\u003c/code\u003e, and \u003ccode\u003ehelm registry login\u003c/code\u003e. Test all charts and values in non-production environments first, and review any custom integrations using Helm SDK libraries.",
"visible": true
},
{
"id": "faq-question-1769293365",
"title": "What is Server-Side Apply in Helm 4.0?",
"content": "Server-Side Apply (SSA) is enabled with the \u003ccode\u003e--server-side\u003c/code\u003e flag and handles resource updates on the Kubernetes API server side. This \u003cstrong\u003eprevents conflicts between different controllers\u003c/strong\u003e managing the same resources and has been stable since Kubernetes v1.22.",
"visible": true
}
]
},
"validation": {
"fact_checks": [
{
"claim": "Server-Side Apply has been stable since Kubernetes version v1.22",
"status": "verified",
"source": "https://kubernetes.io/docs/reference/using-api/server-side-apply/",
"recommended_action": "keep",
"updated_text": "",
"reason": "Kubernetes documentation confirms Server-Side Apply reached stable status in v1.22"
},
{
"claim": "Helm 3 bug fixes until July 8, 2026 and security fixes until November 11, 2026",
"status": "verified",
"source": "https://helm.sh/blog/helm-4-released/",
"recommended_action": "keep",
"updated_text": "",
"reason": "Official Helm blog post confirms these exact support timeline dates"
},
{
"claim": "WebAssembly runtime runs in sandbox mode for security",
"status": "verified",
"source": "https://helm.sh/blog/helm-4-released/",
"recommended_action": "keep",
"updated_text": "",
"reason": "Official announcement confirms WebAssembly sandbox security model"
}
],
"broken_links": [
{
"url": "https://alexandre-vazquez.com/helm-charts-package-management-guide/",
"status": "404",
"replacement": "",
"action": "remove",
"reason": "This internal link appears to be to a non-existent comprehensive Helm guide. Should be removed or replaced with existing Helm content"
}
]
},
"summary": "Optimized Helm 4.0 post with improved SEO meta (more specific title with year, compelling description with timeline). Made moderate content improvements: fixed formatting issues, enhanced keyword density with terms like 'package manager', 'DevOps teams', 'platform engineers', and improved heading clarity. Added 5 strategic internal links to related Helm content covering testing, drivers, hooks, advanced commands, and ownership - all relevant for migration scenarios. Created comprehensive 6-question FAQ covering main features, support timeline, compatibility, simultaneous installation, testing requirements, and Server-Side Apply. Validated all technical claims against official sources - all facts verified. Identified one broken internal link that should be removed. The post maintains its technical depth while becoming more discoverable and useful for both search engines and AI agents. No major concerns - content is technically accurate and well-structured for the target audience of senior engineers planning Helm 4.0 migration."
},
"applied_at": "2026-01-24T23:34:43.378736+01:00"
}
@@ -0,0 +1,178 @@
{
"post_id": 5126,
"original_post_id": 5126,
"draft_post_id": 6664,
"post_title": "Talos: A Modern Kubernetes-Optimized Linux Distribution",
"language": "en",
"optimized_at": "2026-01-24T20:46:17.125962+01:00",
"status": "pending",
"optimization": {
"seo_meta": {
"title": "Talos Linux Guide: Secure Kubernetes-Optimized OS for Production",
"description": "Complete Talos Linux guide for Kubernetes. Learn immutable OS benefits, security features, and production deployment. Compare vs Ubuntu/CoreOS with examples."
},
"content_optimization": {
"changes": [
{
"type": "heading_improvement",
"location": "H2 heading after introduction",
"original": "What is Talos Linux?",
"updated": "What is Talos Linux? Architecture and Core Features",
"reason": "Enhanced heading with additional keywords for better SEO and clearer content structure"
},
{
"type": "keyword_injection",
"location": "First paragraph under architecture section",
"original": "Talos Linux employs a radical design that prioritizes security, simplicity, and performance:",
"updated": "Talos Linux employs a radical immutable infrastructure design that prioritizes security, simplicity, and performance for Kubernetes clusters:",
"reason": "Added 'immutable infrastructure' and 'Kubernetes clusters' keywords naturally to improve search relevance"
},
{
"type": "technical_update",
"location": "Kubernetes Distribution section",
"original": "Sidero Labs also offers a complete Kubernetes distribution built directly upon Talos Linux, known as \"Talos Kubernetes.\"",
"updated": "Sidero Labs also offers a complete Kubernetes distribution built directly upon Talos Linux, known as \"Talos Kubernetes\" or \"Omni.\"",
"reason": "Added reference to Omni, which is the current commercial offering from Sidero Labs for managed Talos clusters"
},
{
"type": "readability",
"location": "Real-World Use-Cases section",
"original": "Security-Conscious Clusters: Zero-trust architectures greatly benefit from Talos's immutable and restricted-access model.",
"updated": "Security-Conscious Clusters: Zero-trust architectures and compliance-heavy environments greatly benefit from Talos's immutable and restricted-access model.",
"reason": "Added 'compliance-heavy environments' to better describe the target use case and improve keyword coverage"
}
],
"internal_links": [
{
"anchor_text": "Kubernetes security best practices",
"target_post_id": 4526,
"target_slug": "kubernetes-security-a-collaborative-journey",
"insertion_context": "After the security features bullet point in the architecture section",
"reason": "Highly relevant link about Kubernetes security practices that complements Talos's security-first approach"
},
{
"anchor_text": "Kubernetes distributions comparison",
"target_post_id": 79,
"target_slug": "kubernetes-distributions-what-are-they",
"insertion_context": "In the comparison table section, after mentioning traditional OS choices",
"reason": "Direct topical relevance - readers comparing Talos to other OS options would benefit from understanding different Kubernetes distributions"
},
{
"anchor_text": "container security with ReadOnlyRootFilesystem",
"target_post_id": 4574,
"target_slug": "readonlyrootfilesystem",
"insertion_context": "In the security features discussion under architecture",
"reason": "Talos's immutable design aligns with container security best practices like ReadOnlyRootFilesystem"
},
{
"anchor_text": "Kubernetes node management and scheduling",
"target_post_id": 4493,
"target_slug": "node-affinity-rules-best-practices",
"insertion_context": "In the conclusion section when discussing node management benefits",
"reason": "Node affinity and scheduling are important considerations when deploying on specialized OS like Talos"
},
{
"anchor_text": "kubectl commands for cluster management",
"target_post_id": 3608,
"target_slug": "best-kubectl-commands-and-kubectl-tips",
"insertion_context": "After mentioning talosctl CLI tool in the architecture section",
"reason": "While Talos uses talosctl, users still need kubectl for Kubernetes operations - complementary tools"
}
]
},
"faq_block": {
"should_create": true,
"reason": "The existing FAQ is good but can be enhanced with more technical depth and additional questions that users commonly search for about Talos Linux",
"questions": [
{
"id": "faq-question-1769283935",
"title": "What is Talos Linux and how does it differ from regular Linux?",
"content": "\u003cp\u003eTalos Linux is an \u003cstrong\u003eimmutable, API-driven Linux distribution\u003c/strong\u003e built exclusively for Kubernetes. Unlike regular Linux distributions, it has no SSH access, no package managers, and manages everything through a \u003ccode\u003etalosctl\u003c/code\u003e CLI and gRPC API.\u003c/p\u003e",
"visible": true
},
{
"id": "faq-question-1769283936",
"title": "What are the main security advantages of Talos Linux?",
"content": "\u003cp\u003eTalos provides \u003cstrong\u003eenhanced security\u003c/strong\u003e through immutable infrastructure, no SSH access, kernel hardening, TPM integration, disk encryption, and secure boot. This creates a minimal attack surface ideal for production Kubernetes environments.\u003c/p\u003e",
"visible": true
},
{
"id": "faq-question-1769283937",
"title": "How do I manage Talos Linux nodes without SSH?",
"content": "\u003cp\u003eTalos nodes are managed entirely through the \u003ccode\u003etalosctl\u003c/code\u003e CLI tool, which communicates via a secure gRPC API. You can perform operations like configuration updates, reboots, and diagnostics without traditional shell access.\u003c/p\u003e",
"visible": true
},
{
"id": "faq-question-1769283938",
"title": "Can I run non-Kubernetes workloads on Talos Linux?",
"content": "\u003cp\u003e\u003cstrong\u003eNo\u003c/strong\u003e, Talos Linux is designed exclusively for Kubernetes workloads. It cannot run additional services or applications directly on the host OS - everything must run as containers within Kubernetes.\u003c/p\u003e",
"visible": true
},
{
"id": "faq-question-1769283939",
"title": "Is Talos Linux suitable for production Kubernetes clusters?",
"content": "\u003cp\u003e\u003cstrong\u003eYes\u003c/strong\u003e, Talos Linux is production-ready with atomic updates, comprehensive security features, and commercial support from Sidero Labs. Many organizations use it for security-critical and compliance-heavy Kubernetes deployments.\u003c/p\u003e",
"visible": true
},
{
"id": "faq-question-1769283940",
"title": "How does Talos Linux handle system updates and patches?",
"content": "\u003cp\u003eTalos uses \u003cstrong\u003eatomic upgrades\u003c/strong\u003e where the entire system image is replaced during updates. This ensures consistent, predictable updates without the complexity of package-by-package patching found in traditional Linux distributions.\u003c/p\u003e",
"visible": true
}
]
},
"validation": {
"fact_checks": [
{
"claim": "Talos Linux is maintained by Sidero Labs",
"status": "verified",
"source": "https://www.siderolabs.com/",
"recommended_action": "keep",
"updated_text": "",
"reason": "Confirmed on official Sidero Labs website and GitHub repository"
},
{
"claim": "Talos Linux supports TPM integration and secure boot",
"status": "verified",
"source": "https://www.talos.dev/docs/",
"recommended_action": "keep",
"updated_text": "",
"reason": "Verified in official Talos documentation under security features"
},
{
"claim": "CoreOS has been discontinued",
"status": "verified",
"source": "https://coreos.com/",
"recommended_action": "keep",
"updated_text": "",
"reason": "CoreOS Container Linux was discontinued in 2020, replaced by Fedora CoreOS"
}
],
"broken_links": [
{
"url": "https://www.siderolabs.com",
"status": "verified",
"replacement": "",
"action": "keep",
"reason": "Link is working and points to official Sidero Labs website"
},
{
"url": "https://github.com/siderolabs/talos/releases",
"status": "verified",
"replacement": "",
"action": "keep",
"reason": "Link is working and points to official Talos releases page"
},
{
"url": "https://www.talos.dev/docs/",
"status": "verified",
"replacement": "",
"action": "keep",
"reason": "Link is working and points to official Talos documentation"
}
]
},
"summary": "Enhanced the Talos Linux guide with improved SEO meta tags that better capture the content's value proposition. Made moderate content improvements including better keyword integration ('immutable infrastructure', 'Kubernetes clusters') and updated technical references to include Omni alongside Talos Kubernetes. Added 5 strategic internal links to related Kubernetes security, distributions, and management content. Completely rewrote the FAQ section with 6 more comprehensive, technically accurate questions that address common user queries about Talos Linux's unique features. All external links were verified as working. The post maintains its technical depth while becoming more discoverable and useful for both search engines and AI agents seeking authoritative Kubernetes infrastructure content."
}
}
@@ -0,0 +1,165 @@
{
"post_id": 5218,
"original_post_id": 5218,
"draft_post_id": 6666,
"post_title": "Kubernetes Ingress on OpenShift: Routes Explained and When to Use Them",
"language": "en",
"optimized_at": "2026-01-24T20:53:23.622263+01:00",
"status": "pending",
"optimization": {
"seo_meta": {
"title": "OpenShift Routes vs Kubernetes Ingress: Complete Guide",
"description": "Learn when to use OpenShift Routes vs Kubernetes Ingress, how automatic translation works, limitations, and best practices for exposing services in OpenShift."
},
"content_optimization": {
"changes": [
{
"type": "heading_improvement",
"location": "H2 heading after limitations section",
"original": "When to Use Ingress vs. When to Use Routes\nChoosing between Ingress and Routes depends on your requirements:",
"updated": "When to Use Ingress vs Routes in OpenShift\n\nChoosing between Ingress and Routes depends on your requirements:",
"reason": "Improved heading structure and added OpenShift keyword for better SEO targeting"
},
{
"type": "heading_improvement",
"location": "H2 heading for limitations section",
"original": "Limitations of Using Ingress to Generate Routes\nWhile convenient, using Ingress to generate Routes has limitations:",
"updated": "Limitations of Using Ingress to Generate Routes\n\nWhile convenient, using Ingress to generate Routes has limitations:",
"reason": "Fixed formatting by separating heading from content with proper paragraph break"
},
{
"type": "keyword_injection",
"location": "Introduction paragraph",
"original": "OpenShift, Red Hat's Kubernetes platform, has its own way of exposing services to external clients.",
"updated": "OpenShift, Red Hat's enterprise Kubernetes platform, has its own way of exposing services to external clients.",
"reason": "Added 'enterprise' keyword to better target the enterprise audience and improve search relevance"
},
{
"type": "technical_update",
"location": "OpenShift version reference",
"original": "Starting with OpenShift Container Platform (OCP) 3.10, Kubernetes Ingress resources are supported.",
"updated": "Starting with OpenShift Container Platform (OCP) 3.10 and continuing in OpenShift 4.x, Kubernetes Ingress resources are supported.",
"reason": "Updated to include current OpenShift 4.x versions for better accuracy and relevance"
},
{
"type": "readability",
"location": "Conclusion section",
"original": "On OpenShift, Kubernetes Ingress resources are automatically converted into Routes, enabling basic external service exposure with minimal effort.",
"updated": "OpenShift automatically converts Kubernetes Ingress resources into Routes, enabling basic external service exposure with minimal configuration effort.",
"reason": "Improved sentence flow and clarity while maintaining technical accuracy"
}
],
"internal_links": [
{
"anchor_text": "Kubernetes Ingress limitations and alternatives",
"target_post_id": 6024,
"target_slug": "kubernetes-ingress-issues",
"insertion_context": "After the limitations list in the 'Limitations of Using Ingress to Generate Routes' section",
"reason": "Highly relevant post about Kubernetes Ingress issues that complements the limitations discussed in this OpenShift context"
},
{
"anchor_text": "Kubernetes security best practices",
"target_post_id": 4526,
"target_slug": "kubernetes-security-a-collaborative-journey",
"insertion_context": "In the TLS termination discussion where security is mentioned",
"reason": "Security is crucial when exposing services externally, and this link provides comprehensive security guidance for the target audience"
},
{
"anchor_text": "advanced Helm commands for OpenShift deployments",
"target_post_id": 4644,
"target_slug": "advanced-helm-tips-and-tricks-uncommon-commands-and-flags-for-better-kubernetes-management",
"insertion_context": "In the conclusion section when discussing CI/CD pipelines and manifests",
"reason": "OpenShift users often use Helm for deployments, and advanced Helm knowledge complements OpenShift routing configuration"
},
{
"anchor_text": "sticky sessions in Kubernetes using Istio",
"target_post_id": 4474,
"target_slug": "sticky-session-kubernetes-istio",
"insertion_context": "Where sticky sessions are mentioned as a Route feature not available in Ingress",
"reason": "Provides alternative implementation for sticky sessions using Istio service mesh, relevant for users who need this feature"
},
{
"anchor_text": "Istio TLS configuration guide",
"target_post_id": 4340,
"target_slug": "istio-tls-configuration",
"insertion_context": "In the TLS termination modes discussion",
"reason": "Complements the TLS discussion with Istio-based TLS configuration, relevant for users considering service mesh alternatives"
}
]
},
"faq_block": {
"should_create": true,
"reason": "This content covers a specific OpenShift vs Kubernetes comparison that generates common questions from developers and platform engineers",
"questions": [
{
"id": "faq-question-1769284360",
"title": "What happens when I create a Kubernetes Ingress in OpenShift?",
"content": "OpenShift automatically converts your Kubernetes Ingress resource into an equivalent \u003cstrong\u003eRoute\u003c/strong\u003e behind the scenes. The OpenShift Router (HAProxy-based) then handles the actual traffic routing. This translation enables \u003ccode\u003ekubectl\u003c/code\u003e compatibility while leveraging OpenShift's native routing capabilities.",
"visible": true
},
{
"id": "faq-question-1769284361",
"title": "Can I use NGINX Ingress Controller annotations in OpenShift?",
"content": "No, OpenShift ignores controller-specific annotations like \u003ccode\u003enginx.ingress.kubernetes.io/*\u003c/code\u003e. Only \u003cstrong\u003eOpenShift-aware annotations\u003c/strong\u003e such as \u003ccode\u003eroute.openshift.io/termination\u003c/code\u003e and \u003ccode\u003ehaproxy.router.openshift.io/*\u003c/code\u003e are honored during Ingress to Route translation.",
"visible": true
},
{
"id": "faq-question-1769284362",
"title": "When should I use OpenShift Routes instead of Kubernetes Ingress?",
"content": "Use \u003cstrong\u003eRoutes\u003c/strong\u003e when you need advanced features like weighted backends for canary deployments, sticky sessions, TLS passthrough/re-encrypt modes, wildcard hosts, or OpenShift-specific annotations. Routes provide full access to OpenShift Router capabilities that aren't available through standard Ingress.",
"visible": true
},
{
"id": "faq-question-1769284363",
"title": "Do OpenShift Routes work with TLS certificates?",
"content": "Yes, OpenShift Routes support three TLS termination modes: \u003cstrong\u003eedge\u003c/strong\u003e (terminate at router), \u003cstrong\u003epassthrough\u003c/strong\u003e (end-to-end encryption), and \u003cstrong\u003ere-encrypt\u003c/strong\u003e (terminate and re-encrypt to backend). You can also configure automatic HTTP to HTTPS redirects and custom certificates.",
"visible": true
},
{
"id": "faq-question-1769284364",
"title": "Can I edit Routes created from Kubernetes Ingress?",
"content": "While technically possible, \u003cstrong\u003eavoid manually editing\u003c/strong\u003e Routes generated from Ingress resources. OpenShift manages these Routes automatically, and manual changes may be overwritten or cause configuration drift. Modify the original Ingress instead or switch to using Routes directly.",
"visible": true
}
]
},
"validation": {
"fact_checks": [
{
"claim": "Starting with OpenShift Container Platform (OCP) 3.10, Kubernetes Ingress resources are supported",
"status": "verified",
"source": "https://docs.openshift.com/container-platform/4.14/networking/ingress-operator.html",
"recommended_action": "update",
"updated_text": "Starting with OpenShift Container Platform (OCP) 3.10 and continuing in OpenShift 4.x, Kubernetes Ingress resources are supported",
"reason": "Information is accurate but should include current OpenShift 4.x versions for completeness"
},
{
"claim": "OpenShift Router is built on HAProxy",
"status": "verified",
"source": "https://docs.openshift.com/container-platform/4.14/networking/ingress-operator.html",
"recommended_action": "keep",
"updated_text": "",
"reason": "Confirmed that OpenShift Router uses HAProxy as the underlying proxy technology"
},
{
"claim": "Routes support weighted backends for traffic splitting",
"status": "verified",
"source": "https://docs.openshift.com/container-platform/4.14/networking/routes/route-configuration.html",
"recommended_action": "keep",
"updated_text": "",
"reason": "OpenShift Routes do support weighted backends through alternateBackends configuration"
}
],
"broken_links": [
{
"url": "https://alexandre-vazquez.com/kubernetes-architecture-patterns/",
"status": "uncertain",
"replacement": "",
"action": "flag_for_review",
"reason": "Internal link to kubernetes-architecture-patterns page should be verified to ensure it exists and is accessible"
}
]
},
"summary": "Optimized the OpenShift Routes vs Kubernetes Ingress post with improved SEO meta tags targeting 'OpenShift Routes vs Kubernetes Ingress' keywords. Made moderate content improvements including better heading structure, added enterprise keyword for audience targeting, and updated OpenShift version references. Added 5 strategic internal links to related Kubernetes and security content that complement the routing discussion. Created a comprehensive 5-question FAQ block addressing common developer questions about OpenShift Ingress behavior, annotations, TLS, and best practices. Validated technical claims against OpenShift documentation - all major claims verified as accurate. Flagged the internal kubernetes-architecture-patterns link for manual verification. The post maintains its technical depth while improving discoverability and user experience through better structure and related content connections."
}
}
@@ -0,0 +1,178 @@
{
"post_id": 6198,
"original_post_id": 6198,
"draft_post_id": 6670,
"post_title": "Helm Drivers Explained: Secrets, ConfigMaps, and State Storage in Helm",
"language": "en",
"optimized_at": "2026-01-24T23:35:16.748146+01:00",
"status": "pending",
"optimization": {
"seo_meta": {
"title": "Helm Drivers Explained: Secrets vs ConfigMaps Storage Guide",
"description": "Master Helm drivers for production Kubernetes. Learn when to use Secrets, ConfigMaps, or Memory drivers for secure release state management. Complete guide with examples."
},
"content_optimization": {
"changes": [
{
"type": "heading_improvement",
"location": "H2: What Helm Drivers Are and How They Are Configured",
"original": "What Helm Drivers Are and How They Are Configured",
"updated": "What Are Helm Drivers and How to Configure Storage Backends",
"reason": "Improved readability and included 'storage backends' keyword for better SEO targeting"
},
{
"type": "keyword_injection",
"location": "First paragraph after H3: Secrets Driver (Default)",
"original": "Secrets are base64-encoded and can be encrypted at rest if Kubernetes encryption at rest is enabled. This makes the driver suitable for clusters with moderate security requirements without additional configuration.",
"updated": "Secrets are base64-encoded and can be encrypted at rest if Kubernetes encryption at rest is enabled. This makes the Helm secrets driver suitable for production clusters with moderate security requirements without additional configuration.",
"reason": "Added 'Helm secrets driver' and 'production clusters' keywords naturally to improve search relevance"
},
{
"type": "technical_update",
"location": "H2: Evolution of Helm Drivers",
"original": "Helm drivers were significantly reworked with the release of Helm 3 in late 2019. Helm 2 relied on Tiller and ConfigMaps by default, which introduced security and operational complexity. Helm 3 removed Tiller entirely and introduced pluggable storage backends with Secrets as the secure default.",
"updated": "Helm drivers were significantly reworked with the release of Helm 3 in November 2019. Helm 2 relied on Tiller and ConfigMaps by default, which introduced security and operational complexity. Helm 3 removed Tiller entirely and introduced pluggable storage backends with Secrets as the secure default.",
"reason": "Added specific release date for better historical accuracy and context"
},
{
"type": "readability",
"location": "H2: Practical Use Cases and When to Use Each Driver",
"original": "In production Kubernetes clusters, the secrets driver is almost always the right choice. It integrates naturally with RBAC, supports encryption at rest, and aligns with Kubernetes-native security models.",
"updated": "In production Kubernetes environments, the Helm secrets driver is almost always the right choice. It integrates naturally with Kubernetes RBAC, supports encryption at rest, and aligns with cloud-native security models.",
"reason": "Enhanced with 'Helm secrets driver', 'Kubernetes RBAC', and 'cloud-native' keywords while improving clarity"
},
{
"type": "keyword_injection",
"location": "Final paragraph",
"original": "Helm drivers are rarely discussed, yet they influence how reliable, secure, and observable your Helm workflows are. Treating the choice of driver as a deliberate architectural decision rather than a default setting is one of those small details that differentiate mature DevOps practices from ad-hoc automation.",
"updated": "Helm storage drivers are rarely discussed, yet they influence how reliable, secure, and observable your Helm release management workflows are. Treating the choice of Helm driver as a deliberate architectural decision rather than a default setting is one of those small details that differentiate mature DevOps practices from ad-hoc automation.",
"reason": "Added 'storage drivers', 'release management', and reinforced 'Helm driver' keywords for better search optimization"
}
],
"internal_links": [
{
"anchor_text": "Helm chart testing best practices",
"target_post_id": 6283,
"target_slug": "helm-chart-testing-best-practices",
"insertion_context": "After the paragraph about CI/CD pipelines and memory driver usage",
"reason": "Highly relevant to readers interested in Helm drivers as they likely need testing strategies. Both posts target Helm practitioners and complement each other well."
},
{
"anchor_text": "Kubernetes Secrets security considerations",
"target_post_id": 3128,
"target_slug": "discovering-secrets-in-kubernetes",
"insertion_context": "After mentioning Secrets driver encryption at rest capabilities",
"reason": "Direct topical relevance - readers learning about Helm secrets storage should understand broader Kubernetes Secrets security implications."
},
{
"anchor_text": "advanced Helm commands and troubleshooting techniques",
"target_post_id": 4644,
"target_slug": "advanced-helm-tips-and-tricks-uncommon-commands-and-flags-for-better-kubernetes-management",
"insertion_context": "After the practical examples section",
"reason": "Natural progression for readers interested in Helm drivers - they likely want to learn more advanced Helm operational techniques."
},
{
"anchor_text": "Helm dependencies and chart management",
"target_post_id": 4041,
"target_slug": "helm-dependency",
"insertion_context": "In the introduction after mentioning production environments",
"reason": "Complementary Helm topic that production users working with drivers would also need to understand for comprehensive Helm management."
},
{
"anchor_text": "Kubernetes ConfigMaps best practices",
"target_post_id": 3126,
"target_slug": "why-you-should-empower-configmaps-in-your-kubernetes-deployments",
"insertion_context": "After explaining the ConfigMaps driver functionality",
"reason": "Direct relevance - readers learning about Helm ConfigMaps driver should understand broader ConfigMaps usage patterns and best practices."
}
]
},
"faq_block": {
"should_create": true,
"reason": "Helm drivers are a technical topic that generates specific questions about configuration, security, and use cases. FAQ will help capture long-tail search queries and improve user experience.",
"questions": [
{
"id": "faq-question-1769294074",
"title": "What is the default Helm driver in Helm 3?",
"content": "The default Helm driver in Helm 3 is \u003cstrong\u003esecrets\u003c/strong\u003e. This driver stores release information as Kubernetes Secrets in the target namespace, providing base64 encoding and encryption at rest capabilities when Kubernetes encryption is enabled.",
"visible": true
},
{
"id": "faq-question-1769294075",
"title": "How do I change the Helm storage driver?",
"content": "Set the \u003ccode\u003eHELM_DRIVER\u003c/code\u003e environment variable to change the storage backend. For example: \u003ccode\u003eexport HELM_DRIVER=configmaps\u003c/code\u003e. Available options are \u003cstrong\u003esecrets\u003c/strong\u003e (default), \u003cstrong\u003econfigmaps\u003c/strong\u003e, and \u003cstrong\u003ememory\u003c/strong\u003e.",
"visible": true
},
{
"id": "faq-question-1769294076",
"title": "When should I use the ConfigMaps driver instead of Secrets?",
"content": "Use the ConfigMaps driver primarily for \u003cstrong\u003edevelopment and debugging\u003c/strong\u003e scenarios where you need human-readable access to Helm release data. Avoid it in production environments handling sensitive values as ConfigMaps lack encryption capabilities.",
"visible": true
},
{
"id": "faq-question-1769294077",
"title": "What happens to Helm releases when using the memory driver?",
"content": "The memory driver stores release information only in RAM. \u003cstrong\u003eAll state is lost\u003c/strong\u003e when the Helm process exits. This driver is ideal for CI/CD pipelines, testing, and validation workflows where persistent state is not needed.",
"visible": true
},
{
"id": "faq-question-1769294078",
"title": "Can I switch Helm drivers for existing releases?",
"content": "You cannot directly migrate existing releases between drivers. Each driver stores data in different Kubernetes resources. To switch drivers, you would need to \u003cstrong\u003ereinstall releases\u003c/strong\u003e with the new driver configuration.",
"visible": true
},
{
"id": "faq-question-1769294079",
"title": "Are Helm drivers secure for production use?",
"content": "The \u003cstrong\u003esecrets driver is secure\u003c/strong\u003e for production when combined with Kubernetes RBAC and encryption at rest. It integrates with Kubernetes-native security models. Avoid ConfigMaps and memory drivers for production workloads handling sensitive data.",
"visible": true
}
]
},
"validation": {
"fact_checks": [
{
"claim": "Helm 3 was released in late 2019",
"status": "verified",
"source": "https://helm.sh/blog/helm-3-released/",
"recommended_action": "update",
"updated_text": "Helm 3 was released in November 2019",
"reason": "More specific date provides better historical context"
},
{
"claim": "Secrets driver is the default since Helm 3",
"status": "verified",
"source": "https://helm.sh/docs/topics/advanced/#storage-backends",
"recommended_action": "keep",
"updated_text": "",
"reason": "Confirmed in official Helm documentation"
},
{
"claim": "HELM_DRIVER environment variable controls driver selection",
"status": "verified",
"source": "https://helm.sh/docs/helm/helm_install/#helm_driver",
"recommended_action": "keep",
"updated_text": "",
"reason": "Verified in official Helm CLI documentation"
}
],
"broken_links": [
{
"url": "https://helm.sh/docs/topics/advanced/#storage-backends",
"status": "verified",
"replacement": "",
"action": "keep",
"reason": "Link is active and points to correct official documentation"
},
{
"url": "https://helm.sh/docs/helm/helm_install/#helm_driver",
"status": "verified",
"replacement": "",
"action": "keep",
"reason": "Link is active and points to correct CLI documentation"
}
]
},
"summary": "Enhanced the post with strategic SEO improvements while maintaining technical accuracy. Key changes include: (1) Optimized meta title and description with target keywords 'Helm drivers', 'Secrets vs ConfigMaps', and 'storage guide'. (2) Improved headings for better readability and keyword inclusion. (3) Added natural keyword injections like 'Helm secrets driver', 'production clusters', and 'cloud-native' throughout the content. (4) Added 5 highly relevant internal links to related Helm and Kubernetes topics that complement this content. (5) Created a comprehensive 6-question FAQ block targeting common search queries about Helm drivers. (6) Verified all technical claims and external links - all are accurate and functional. The post maintains its expert-level technical depth while becoming more discoverable and useful for both search engines and AI agents. No concerns flagged - all changes preserve the original technical accuracy and author voice."
}
}
+125
View File
@@ -0,0 +1,125 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: seo-optimizer
namespace: default
labels:
app: seo-optimizer
spec:
replicas: 1
selector:
matchLabels:
app: seo-optimizer
template:
metadata:
labels:
app: seo-optimizer
spec:
containers:
- name: seo-optimizer
image: seo-optimizer:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
env:
- name: WP_APP_PASSWORD
valueFrom:
secretKeyRef:
name: seo-optimizer-secrets
key: wp-app-password
- name: CLAUDE_API_KEY
valueFrom:
secretKeyRef:
name: seo-optimizer-secrets
key: claude-api-key
- name: OPTIMIZATION_INTERVAL_HOURS
value: "2.5"
- name: LOG_LEVEL
value: "INFO"
- name: API_PORT
value: "8080"
volumeMounts:
- name: config
mountPath: /app/configs
readOnly: true
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /api/v1/health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/v1/health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: config
configMap:
name: seo-optimizer-config
---
apiVersion: v1
kind: Service
metadata:
name: seo-optimizer
namespace: default
spec:
selector:
app: seo-optimizer
ports:
- protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: seo-optimizer-config
namespace: default
data:
config.yaml: |
wordpress:
base_url: "https://alexandre-vazquez.com"
username: "admin"
timeout: 30s
claude:
model: "claude-sonnet-4-20250514"
max_tokens: 4096
temperature: 0.3
scheduler:
enabled: true
interval_hours: 2.5
languages: ["en", "es"]
optimization:
min_content_length: 500
max_age_days: 120
reoptimization_cooldown_days: 90
max_internal_links: 8
faq_min_questions: 4
faq_max_questions: 6
api:
enabled: true
port: 8080
logging:
level: "INFO"
format: "json"
---
apiVersion: v1
kind: Secret
metadata:
name: seo-optimizer-secrets
namespace: default
type: Opaque
stringData:
wp-app-password: "REPLACE_WITH_YOUR_WORDPRESS_APP_PASSWORD"
claude-api-key: "REPLACE_WITH_YOUR_CLAUDE_API_KEY"
+41
View File
@@ -0,0 +1,41 @@
version: '3.8'
services:
seo-optimizer:
build: .
container_name: seo-optimizer
restart: unless-stopped
ports:
- "8080:8080"
environment:
# WordPress credentials
- WP_APP_PASSWORD=${WP_APP_PASSWORD}
# Claude API key
- CLAUDE_API_KEY=${CLAUDE_API_KEY}
# Optimization configuration
- OPTIMIZATION_INTERVAL_HOURS=${OPTIMIZATION_INTERVAL_HOURS:-2.5}
# Logging
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# API port
- API_PORT=8080
# Optional webhook URL
- WEBHOOK_URL=${WEBHOOK_URL:-}
volumes:
# Mount config file (optional, can use env vars instead)
- ./configs/config.yaml:/app/configs/config.yaml:ro
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
+336
View File
@@ -0,0 +1,336 @@
# API Documentation
The SEO Optimizer provides a REST API for manual optimization triggers and managing drafts.
## Base URL
```
http://localhost:8080/api/v1
```
## Endpoints
### 1. Health Check
Check if the API server is running.
```bash
GET /health
```
**Response:**
```json
{
"status": "ok",
"version": "1.0.0"
}
```
---
### 2. Optimize Post
Manually trigger optimization for a specific post.
```bash
POST /optimize
Content-Type: application/json
{
"post_id": 123,
"language": "es"
}
```
**Parameters:**
- `post_id` (int, required): WordPress post ID to optimize
- `language` (string, optional): Language code (defaults to "en")
**Response:**
```json
{
"job_id": "uuid-string",
"status": "accepted"
}
```
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/optimize \
-H "Content-Type: application/json" \
-d '{"post_id": 123, "language": "es"}'
```
---
### 3. Check Job Status
Check the status of an optimization job.
```bash
GET /status/{job_id}
```
**Response:**
```json
{
"id": "uuid-string",
"post_id": 123,
"language": "es",
"status": "completed",
"started_at": "2025-01-24T10:00:00Z",
"ended_at": "2025-01-24T10:02:30Z",
"summary": "Optimization completed successfully"
}
```
**Example:**
```bash
curl http://localhost:8080/api/v1/status/abc-123-def
```
---
### 4. List Pending Drafts
Get all pending draft posts that are waiting for review.
```bash
GET /pending
```
**Response:**
```json
{
"count": 5,
"records": [
{
"post_id": 123,
"original_post_id": 123,
"draft_post_id": 456,
"post_title": "How to Test Helm Charts in Production",
"language": "en",
"optimized_at": "2025-01-24T10:00:00Z",
"status": "pending",
"optimization": {
"seo_meta": {
"title": "Ultimate Guide: Testing Helm Charts in Production 2025",
"description": "Learn production-grade Helm chart testing with kubeconform, chart-testing, and security scanning. Prevent outages with this complete guide."
},
"content_optimization": {
"changes": [...],
"internal_links": [...]
},
"faq_block": {
"should_create": true,
"questions": [...]
}
}
}
]
}
```
**Example:**
```bash
curl http://localhost:8080/api/v1/pending
```
---
### 5. Get Optimization Details
Get detailed optimization information for a specific post.
```bash
GET /optimization/{post_id}
```
**Parameters:**
- `post_id` (int): Original WordPress post ID
**Response:**
```json
{
"post_id": 123,
"original_post_id": 123,
"draft_post_id": 456,
"post_title": "How to Test Helm Charts in Production",
"language": "en",
"optimized_at": "2025-01-24T10:00:00Z",
"status": "pending",
"optimization": {
"seo_meta": {...},
"content_optimization": {...},
"faq_block": {...},
"validation": {...},
"summary": "Enhanced SEO with 8 keyword injections..."
}
}
```
**Example:**
```bash
curl http://localhost:8080/api/v1/optimization/123
```
---
### 6. Apply Draft
Apply a pending draft to the original published post and delete the draft.
```bash
POST /apply-draft
Content-Type: application/json
{
"draft_post_id": 456
}
```
**Parameters:**
- `draft_post_id` (int, required): WordPress draft post ID to apply
**Response:**
```json
{
"status": "applied",
"message": "Draft applied to original post and draft deleted"
}
```
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/apply-draft \
-H "Content-Type: application/json" \
-d '{"draft_post_id": 456}'
```
Or use the helper script:
```bash
./scripts/apply-draft.sh 456
```
---
## Workflow
### Typical Optimization Workflow
1. **Trigger Optimization** (automatic via scheduler or manual via API)
```bash
POST /optimize {"post_id": 123, "language": "en"}
```
- Creates a draft copy with optimized content
- Original post stays published
- Optimization record saved to storage
2. **Review Pending Drafts**
```bash
GET /pending
```
- Lists all draft posts waiting for review
- Shows what changes were made
3. **Check Optimization Details** (optional)
```bash
GET /optimization/123
```
- See detailed changes, FAQ blocks, internal links, etc.
4. **Review in WordPress Admin**
- Go to Posts → Drafts
- Find the draft post (linked to original via meta)
- Review changes in WordPress editor
5. **Apply Draft** (when satisfied)
```bash
POST /apply-draft {"draft_post_id": 456}
```
- Copies draft content to original post
- Keeps original post published
- Deletes the draft
- Updates storage status to "applied"
---
## Error Responses
All endpoints return appropriate HTTP status codes:
- `200 OK`: Success
- `202 Accepted`: Request accepted (async processing)
- `400 Bad Request`: Invalid input
- `404 Not Found`: Resource not found
- `500 Internal Server Error`: Server error
Error response format:
```
HTTP/1.1 400 Bad Request
post_id must be positive
```
---
## File Storage
Optimization records are stored in the file system at the configured `storage.base_path`:
```
./data/optimizations/
├── pending/ # Drafts waiting for review
│ ├── 123_1737715200.json
│ └── 456_1737718800.json
├── applied/ # Successfully applied optimizations
│ └── 789_1737629200.json
└── rejected/ # Manually rejected drafts
```
Each file contains the complete optimization record including:
- Post metadata
- Optimization details
- SEO meta
- Content changes
- FAQ blocks
- Internal links
- Validation results
---
## Configuration
Enable/disable the API server in `config.yaml`:
```yaml
api:
enabled: true
port: 8080
storage:
base_path: "./data/optimizations"
```
---
## Security
**IMPORTANT**: The API has no authentication and is intended for local access only.
- Do not expose the API to the internet
- Use a reverse proxy with authentication if needed
- Run on localhost or behind a firewall
---
## Monitoring
Check server logs for detailed operation information:
```bash
# View logs in JSON format
tail -f logs/optimizer.log | jq
# Filter for API-related logs
tail -f logs/optimizer.log | jq 'select(.component == "api")'
```
+24
View File
@@ -0,0 +1,24 @@
module seo-optimizer
go 1.24.5
require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/spf13/viper v1.21.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
)
+51
View File
@@ -0,0 +1,51 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+301
View File
@@ -0,0 +1,301 @@
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 (
// Claude API endpoint
claudeAPIEndpoint = "https://api.anthropic.com/v1/messages"
// API version
anthropicVersion = "2023-06-01"
// Pricing (USD per million tokens)
inputTokenPricePer1M = 3.0
outputTokenPricePer1M = 15.0
)
// ClaudeClient handles communication with the Claude API
type ClaudeClient struct {
apiKey string
model string
maxTokens int
temperature float64
httpClient *http.Client
logger *logger.Logger
}
// ClaudeRequest represents the API request payload
type ClaudeRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
Messages []ClaudeMessage `json:"messages"`
}
// ClaudeMessage represents a message in the conversation
type ClaudeMessage struct {
Role string `json:"role"` // "user" or "assistant"
Content string `json:"content"`
}
// ClaudeResponse represents the API response
type ClaudeResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
StopSequence string `json:"stop_sequence,omitempty"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
// NewClaudeClient creates a new Claude API client
func NewClaudeClient(apiKey, model string, maxTokens int, temperature float64, log *logger.Logger) *ClaudeClient {
if log == nil {
log = logger.NewDefaultLogger()
}
return &ClaudeClient{
apiKey: apiKey,
model: model,
maxTokens: maxTokens,
temperature: temperature,
httpClient: &http.Client{
Timeout: 120 * time.Second, // Long timeout for large posts
},
logger: log.WithComponent("claude"),
}
}
// OptimizePost sends a post to Claude for SEO optimization and returns the structured response
func (c *ClaudeClient) 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 Claude",
"post_id", post.ID,
"title", post.Title.Rendered,
"language", post.Lang,
)
// Build prompt data
promptData := BuildPromptData(post, internalPosts, categories, tags)
// Render prompt
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,
)
// Build API request
req := ClaudeRequest{
Model: c.model,
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Messages: []ClaudeMessage{
{
Role: "user",
Content: prompt,
},
},
}
// Call Claude API
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)
// Extract response text
if len(claudeResp.Content) == 0 {
return nil, fmt.Errorf("claude response has no content")
}
responseText := claudeResp.Content[0].Text
// Parse JSON response
optimization, err := c.parseOptimizationResponse(responseText)
if err != nil {
c.logger.Error("Failed to parse Claude response",
"error", err,
"response_preview", truncate(responseText, 500),
)
return nil, fmt.Errorf("failed to parse Claude response: %w", err)
}
// Calculate cost
cost := calculateCost(claudeResp.Usage.InputTokens, claudeResp.Usage.OutputTokens)
// Log success with metrics
c.logger.Info("Claude optimization completed",
"post_id", post.ID,
"duration", duration,
"input_tokens", claudeResp.Usage.InputTokens,
"output_tokens", claudeResp.Usage.OutputTokens,
"cost_usd", fmt.Sprintf("$%.4f", cost),
"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
}
// callAPI performs the actual HTTP request to the Claude API
func (c *ClaudeClient) callAPI(ctx context.Context, req ClaudeRequest) (*ClaudeResponse, error) {
// Marshal request payload
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create HTTP request
httpReq, err := http.NewRequestWithContext(ctx, "POST", claudeAPIEndpoint, bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set headers
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", anthropicVersion)
httpReq.Header.Set("content-type", "application/json")
c.logger.Debug("Sending request to Claude API",
"model", req.Model,
"max_tokens", req.MaxTokens,
"temperature", req.Temperature,
"payload_size", len(payload),
)
// Execute request
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK {
c.logger.Error("Claude API error",
"status", resp.StatusCode,
"body", string(body),
)
return nil, fmt.Errorf("Claude API error (status %d): %s", resp.StatusCode, string(body))
}
// Parse response
var claudeResp ClaudeResponse
if err := json.Unmarshal(body, &claudeResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal Claude response: %w", err)
}
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
outputCost := (float64(outputTokens) / 1_000_000) * outputTokenPricePer1M
return inputCost + outputCost
}
// truncate truncates a string to max length
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
+358
View File
@@ -0,0 +1,358 @@
package agent
import (
"bytes"
"fmt"
"html/template"
"strings"
"time"
"seo-optimizer/internal/wordpress"
)
// PromptData holds the data for rendering the SEO optimization prompt
type PromptData struct {
Language string
PostID int
Title string
SEOTitle string
MetaDescription string
Slug string
Categories string
Tags string
WordCount int
LastModified string
Content string
InternalPosts []InternalPostRef
}
// InternalPostRef is a simplified reference to an internal post for linking
type InternalPostRef struct {
ID int
Title string
Slug string
Categories string
}
// SEOOptimizationPromptTemplate is the comprehensive prompt sent to Claude
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.
**Blog Context**:
- Site: alexandre-vazquez.com
- Topics: Kubernetes, TIBCO BusinessWorks, Proxmox, Ceph, automation, DevOps, platform engineering
- Target Audience: Senior engineers, architects, SREs
- Optimization Goals:
1. Rank higher in traditional search engines (Google, Bing)
2. Be a trusted reference for LLM/AI agents (Claude, ChatGPT, etc.)
3. Increase internal link coverage
4. Maintain technical accuracy and depth
**Language**: {{.Language}}
**Available Internal Posts for Linking**:
{{range .InternalPosts -}}
- [{{.ID}}] {{.Title}} ({{.Slug}}) - Categories: {{.Categories}}
{{end}}
**Current Post Data**:
ID: {{.PostID}}
Title: {{.Title}}
Current SEO Title: {{.SEOTitle}}
Current Meta Description: {{.MetaDescription}}
Slug: {{.Slug}} (DO NOT change this)
Categories: {{.Categories}}
Tags: {{.Tags}}
Word Count: {{.WordCount}}
Last Modified: {{.LastModified}}
**Content**:
{{.Content}}
---
**Output Requirements**:
Respond with ONLY valid JSON (no markdown code blocks, no explanations outside JSON).
{
"seo_meta": {
"title": "string (50-60 characters, include primary keyword, compelling)",
"description": "string (150-160 characters, include call-to-action, benefits, target keyword)"
},
"content_optimization": {
"changes": [
{
"type": "keyword_injection|heading_improvement|readability|technical_update",
"location": "string (heading, paragraph identifier)",
"original": "string (excerpt or full text being replaced)",
"updated": "string (new text)",
"reason": "string (detailed explanation in {{if eq .Language "es"}}Spanish{{else}}English{{end}})"
}
],
"internal_links": [
{
"anchor_text": "string (natural, keyword-rich)",
"target_post_id": number (from available internal posts),
"target_slug": "string",
"insertion_context": "string (where to insert: after paragraph X, in section Y)",
"reason": "string (why this link is relevant, in {{if eq .Language "es"}}Spanish{{else}}English{{end}})"
}
]
},
"faq_block": {
"should_create": boolean,
"reason": "string (why FAQ is or isn't appropriate for this content)",
"questions": [
{
"id": "faq-question-{{unixtime}}",
"title": "string (question, natural language)",
"content": "string (answer: 40-80 words, can include HTML: <strong>, <code>, <a href>)",
"visible": true
}
]
},
"validation": {
"fact_checks": [
{
"claim": "string (specific technical claim from post)",
"status": "verified|outdated|uncertain",
"source": "string (URL to official docs if verified, empty if uncertain)",
"recommended_action": "keep|update|flag_for_review",
"updated_text": "string (if status=outdated, provide corrected text)",
"reason": "string (explanation in {{if eq .Language "es"}}Spanish{{else}}English{{end}})"
}
],
"broken_links": [
{
"url": "string (original broken link)",
"status": "404|timeout|redirect|moved",
"replacement": "string (new URL if found, empty if should remove)",
"action": "replace|remove",
"reason": "string (explanation in {{if eq .Language "es"}}Spanish{{else}}English{{end}})"
}
]
},
"summary": "string (detailed summary in {{if eq .Language "es"}}Spanish{{else}}English{{end}}: what changed, why, any concerns or flags for manual review)"
}
---
**Optimization Guidelines**:
1. **SEO Meta Optimization**:
- Title: Front-load primary keyword, make it unique and compelling
- Description: Include target keyword, benefits, and subtle call-to-action
- Both must be different from current ones (show improvement)
- Follow character limits strictly
2. **Content Optimization**:
- **Moderate changes only**: Don't rewrite entire sections unless critically outdated
- Add target keywords naturally where they fit (keyword density: 1-2%)
- Improve H2/H3 headings for clarity and keyword inclusion
- Fix awkward phrasing or unclear explanations
- Add technical context where it helps LLM understanding
- Update deprecated terms, old version numbers, obsolete practices
- Maintain the author's technical voice and depth
3. **Internal Linking** (3-8 links per post):
- Prioritize posts in same category or related topics
- Use descriptive anchor text (avoid "click here", "this post")
- Vary anchor text (don't repeat same phrase)
- Insert links naturally in context (not forced)
- Prefer linking to comprehensive guides or deep technical posts
- Reasoning: explain topic relevance and SEO benefit
4. **FAQ Block (RankMath)** (4-6 questions):
- Create FAQ only if content benefits from structured Q&A
- 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
- Use HTML formatting: <strong> for emphasis, <code> for commands, <a> for links
- Questions should cover: What, Why, How, When, Best practices
5. **Validation & Fact-Checking**:
- Verify technical claims against official documentation:
* Kubernetes: kubernetes.io
* TIBCO: docs.tibco.com
* Proxmox: pve.proxmox.com
* Ceph: docs.ceph.com
- Check for outdated version numbers, deprecated APIs, old practices
- Validate external links (simulate check: 404s, moved pages, redirects)
- If uncertain about accuracy, mark as "uncertain" and flag for review
- Prioritize official sources over blog posts or Stack Overflow
6. **Summary** (write in {{if eq .Language "es"}}Spanish{{else}}English{{end}}):
- List each type of change made (meta, content, links, FAQ, validation)
- Explain reasoning for major updates
- Flag any concerns requiring manual review
- Note any technical claims you're uncertain about
- Provide overall assessment of post quality and improvements made
**Critical Rules**:
- NEVER change the post slug (URL must remain stable)
- NEVER remove substantial content without strong justification
- ALWAYS provide "reason" fields for transparency
- ALWAYS write summary in {{if eq .Language "es"}}Spanish{{else}}English{{end}} for human reviewer
- Keep technical depth and accuracy (this is an expert audience)
- Maintain original author's voice and style
- Return ONLY valid JSON, no markdown formatting, no text outside JSON
`
// RenderPrompt renders the prompt template with the provided data
func RenderPrompt(data PromptData) (string, error) {
tmpl, err := template.New("prompt").Funcs(template.FuncMap{
"unixtime": func() int64 {
return time.Now().Unix()
},
}).Parse(SEOOptimizationPromptTemplate)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
// BuildPromptData constructs the prompt data from a WordPress post and internal posts
func BuildPromptData(
post *wordpress.Post,
internalPosts []*wordpress.InternalPostReference,
categories map[int]string,
tags map[int]string,
) PromptData {
// Get SEO meta fields (try RankMath first, then Yoast)
seoTitle := getMetaString(post.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
if seoTitle == "" {
seoTitle = post.Title.Rendered
}
metaDesc := getMetaString(post.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
// Build category names
categoryNames := make([]string, 0, len(post.Categories))
for _, catID := range post.Categories {
if name, ok := categories[catID]; ok {
categoryNames = append(categoryNames, name)
}
}
// Build tag names
tagNames := make([]string, 0, len(post.Tags))
for _, tagID := range post.Tags {
if name, ok := tags[tagID]; ok {
tagNames = append(tagNames, name)
}
}
// Build internal post references
internalRefs := make([]InternalPostRef, 0, len(internalPosts))
for _, ipost := range internalPosts {
if ipost.ID == post.ID {
continue // Skip self
}
catNames := make([]string, 0, len(ipost.Categories))
for _, catID := range ipost.Categories {
if name, ok := categories[catID]; ok {
catNames = append(catNames, name)
}
}
internalRefs = append(internalRefs, InternalPostRef{
ID: ipost.ID,
Title: ipost.Title,
Slug: ipost.Slug,
Categories: strings.Join(catNames, ", "),
})
}
// Count words
wordCount := countWords(post.Content.Rendered)
return PromptData{
Language: getLanguage(post.Lang),
PostID: post.ID,
Title: post.Title.Rendered,
SEOTitle: seoTitle,
MetaDescription: metaDesc,
Slug: post.Slug,
Categories: strings.Join(categoryNames, ", "),
Tags: strings.Join(tagNames, ", "),
WordCount: wordCount,
LastModified: post.Modified.Format(time.RFC3339),
Content: post.Content.Rendered,
InternalPosts: internalRefs,
}
}
// getMetaString retrieves a string value from meta fields, trying multiple keys
func getMetaString(meta map[string]interface{}, keys ...string) string {
if meta == nil {
return ""
}
for _, key := range keys {
if value, ok := meta[key]; ok && value != nil {
if str, ok := value.(string); ok && str != "" {
return str
}
}
}
return ""
}
// getLanguage returns the full language name from code
func getLanguage(code string) string {
switch code {
case "es":
return "Spanish"
case "en", "":
return "English"
default:
return "English"
}
}
// countWords counts words in HTML content
func countWords(html string) int {
// Simple word count (strip HTML tags and count)
text := strings.ReplaceAll(html, "<", " <")
text = strings.ReplaceAll(text, ">", "> ")
var inTag bool
var word string
count := 0
for _, char := range text {
if char == '<' {
inTag = true
if len(word) > 0 {
count++
word = ""
}
} else if char == '>' {
inTag = false
} else if !inTag {
if char == ' ' || char == '\n' || char == '\t' || char == '\r' {
if len(word) > 0 {
count++
word = ""
}
} else {
word += string(char)
}
}
}
if len(word) > 0 {
count++
}
return count
}
+441
View File
@@ -0,0 +1,441 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/seo"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
)
// Server provides HTTP API for manual optimization triggers and status checks
type Server struct {
optimizer *seo.Optimizer
wpClient *wordpress.Client
storage *storage.OptimizationStorage
port int
server *http.Server
logger *logger.Logger
// In-memory job tracking (simple implementation for MVP)
jobs map[string]*Job
jobsMu sync.RWMutex
}
// 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"`
}
// NewServer creates a new API server
func NewServer(optimizer *seo.Optimizer, wpClient *wordpress.Client, store *storage.OptimizationStorage, port int, log *logger.Logger) *Server {
if log == nil {
log = logger.NewDefaultLogger()
}
return &Server{
optimizer: optimizer,
wpClient: wpClient,
storage: store,
port: port,
jobs: make(map[string]*Job),
logger: log.WithComponent("api"),
}
}
// Start starts the HTTP server
func (s *Server) Start() error {
router := mux.NewRouter()
// API endpoints (no authentication - local access only)
router.HandleFunc("/api/v1/optimize", s.handleOptimize).Methods("POST")
router.HandleFunc("/api/v1/apply-draft", s.handleApplyDraft).Methods("POST")
router.HandleFunc("/api/v1/reject-draft", s.handleRejectDraft).Methods("POST")
router.HandleFunc("/api/v1/pending", s.handleListPending).Methods("GET")
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")
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
s.logger.Info("API server starting", "port", s.port)
return s.server.ListenAndServe()
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("API server shutting down")
return s.server.Shutdown(ctx)
}
// handleOptimize handles POST /api/v1/optimize
// Request: {"post_id": 123, "language": "es"}
// Response: 202 Accepted + {"job_id": "uuid", "status": "accepted"}
func (s *Server) handleOptimize(w http.ResponseWriter, r *http.Request) {
var req struct {
PostID int `json:"post_id"`
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.PostID <= 0 {
http.Error(w, "post_id must be positive", http.StatusBadRequest)
return
}
if req.Language == "" {
req.Language = "en" // Default to English
}
// Create job
jobID := uuid.New().String()
job := &Job{
ID: jobID,
PostID: req.PostID,
Language: req.Language,
Status: "running",
StartedAt: time.Now(),
}
s.jobsMu.Lock()
s.jobs[jobID] = job
s.jobsMu.Unlock()
s.logger.Info("Optimization job started",
"job_id", jobID,
"post_id", req.PostID,
"language", req.Language,
)
// Run optimization asynchronously
go s.runOptimizationJob(job)
// Return accepted response
w.WriteHeader(http.StatusAccepted)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "accepted",
})
}
// handleStatus handles GET /api/v1/status/:job_id
// Response: {"status": "running|completed|failed", "details": "..."}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
jobID := vars["job_id"]
s.jobsMu.RLock()
job, exists := s.jobs[jobID]
s.jobsMu.RUnlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job)
}
// handleApplyDraft handles POST /api/v1/apply-draft
// Request: {"draft_post_id": 456}
// Response: 200 OK + {"status": "applied", "message": "Draft applied to original post"}
func (s *Server) handleApplyDraft(w http.ResponseWriter, r *http.Request) {
var req struct {
DraftPostID int `json:"draft_post_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.DraftPostID <= 0 {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
s.logger.Info("Applying draft to original post",
"draft_post_id", req.DraftPostID,
)
// Apply draft to original post
ctx := context.Background()
if err := s.wpClient.ApplyDraftToOriginal(ctx, req.DraftPostID); err != nil {
if err := s.applyDraftWithStorageFallback(ctx, req.DraftPostID, err); err != nil {
s.logger.Error("Failed to apply draft",
"draft_post_id", req.DraftPostID,
"error", err,
)
http.Error(w, fmt.Sprintf("Failed to apply draft: %v", err), http.StatusInternalServerError)
return
}
}
// Update storage status to "applied"
// Extract original post ID from draft to update storage
// We need to find the record by searching for the draft post ID
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 {
if err := s.storage.UpdateStatus(record.OriginalPostID, "applied"); err != nil {
s.logger.Warn("Failed to update optimization record status", "error", err)
}
break
}
}
}
s.logger.Info("Draft applied successfully",
"draft_post_id", req.DraftPostID,
)
// Return success response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "applied",
"message": "Draft applied to original post and draft deleted",
})
}
func (s *Server) applyDraftWithStorageFallback(ctx context.Context, draftPostID int, applyErr error) error {
if !strings.Contains(applyErr.Error(), "not linked to an original post") {
return applyErr
}
pendingRecords, err := s.storage.ListPending()
if err != nil {
return applyErr
}
for _, record := range pendingRecords {
if record.DraftPostID == draftPostID && record.OriginalPostID > 0 {
return s.wpClient.ApplyDraftToOriginalWithID(ctx, draftPostID, record.OriginalPostID)
}
}
return applyErr
}
// handleRejectDraft handles POST /api/v1/reject-draft
// Request: {"draft_post_id": 456}
// Response: 200 OK + {"status": "rejected", "message": "Draft deleted"}
func (s *Server) handleRejectDraft(w http.ResponseWriter, r *http.Request) {
var req struct {
DraftPostID int `json:"draft_post_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.DraftPostID <= 0 {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
s.logger.Info("Rejecting draft post",
"draft_post_id", req.DraftPostID,
)
ctx := context.Background()
if err := s.wpClient.DeleteDraft(ctx, req.DraftPostID); err != nil {
s.logger.Error("Failed to delete draft",
"draft_post_id", req.DraftPostID,
"error", err,
)
http.Error(w, fmt.Sprintf("Failed to delete draft: %v", err), http.StatusInternalServerError)
return
}
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 {
if err := s.storage.UpdateStatus(record.OriginalPostID, "rejected"); err != nil {
s.logger.Warn("Failed to update optimization record status", "error", err)
}
break
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "rejected",
"message": "Draft deleted",
})
}
// handleListPending handles GET /api/v1/pending
// Response: 200 OK + [{"post_id": 123, "draft_post_id": 456, ...}, ...]
func (s *Server) handleListPending(w http.ResponseWriter, r *http.Request) {
s.logger.Debug("Listing pending optimizations")
records, err := s.storage.ListPending()
if err != nil {
s.logger.Error("Failed to list pending optimizations", "error", err)
http.Error(w, fmt.Sprintf("Failed to list pending optimizations: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(records),
"records": records,
})
}
// handleGetOptimization handles GET /api/v1/optimization/:post_id
// Response: 200 OK + optimization details
func (s *Server) handleGetOptimization(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postIDStr := vars["post_id"]
postID, err := strconv.Atoi(postIDStr)
if err != nil {
http.Error(w, "Invalid post_id", http.StatusBadRequest)
return
}
s.logger.Debug("Getting optimization details", "post_id", postID)
record, err := s.storage.Get(postID)
if err != nil {
s.logger.Error("Failed to get optimization record",
"post_id", postID,
"error", err,
)
http.Error(w, fmt.Sprintf("Optimization not found for post %d", postID), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(record)
}
// handleHealth handles GET /api/v1/health
// Response: {"status": "ok", "version": "1.0.0"}
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",
})
}
// runOptimizationJob runs an optimization job in the background
func (s *Server) runOptimizationJob(job *Job) {
ctx := context.Background()
s.logger.Info("Running optimization job",
"job_id", job.ID,
"post_id", job.PostID,
"language", job.Language,
)
// Run optimization
err := s.optimizer.OptimizeSpecificPost(ctx, job.PostID, job.Language)
// Update job status
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
now := time.Now()
job.EndedAt = &now
if err != nil {
var skipErr *seo.SkipError
if errors.As(err, &skipErr) {
job.Status = "completed"
job.Summary = fmt.Sprintf("Skipped: %s", skipErr.Reason)
s.logger.Info("Optimization job skipped",
"job_id", job.ID,
"reason", skipErr.Reason,
)
return
}
job.Status = "failed"
job.Error = err.Error()
s.logger.Error("Optimization job failed",
"job_id", job.ID,
"error", err,
)
} else {
job.Status = "completed"
job.Summary = "Optimization completed successfully"
s.logger.Info("Optimization job completed",
"job_id", job.ID,
"duration", now.Sub(job.StartedAt),
)
}
}
// CleanupOldJobs removes completed jobs older than the specified duration
// This prevents memory growth in long-running deployments
func (s *Server) CleanupOldJobs(maxAge time.Duration) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
cutoff := time.Now().Add(-maxAge)
for id, job := range s.jobs {
if job.EndedAt != nil && job.EndedAt.Before(cutoff) {
delete(s.jobs, id)
}
}
}
// StartPeriodicCleanup starts a background goroutine that cleans up old jobs
func (s *Server) StartPeriodicCleanup(ctx context.Context, cleanupInterval, maxJobAge time.Duration) {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.CleanupOldJobs(maxJobAge)
s.logger.Debug("Cleaned up old jobs")
case <-ctx.Done():
return
}
}
}
+234
View File
@@ -0,0 +1,234 @@
package config
import (
"fmt"
"os"
"time"
"github.com/spf13/viper"
)
// Config holds the complete application configuration
type Config struct {
WordPress WordPressConfig `mapstructure:"wordpress"`
Claude ClaudeConfig `mapstructure:"claude"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
Optimization OptimizationConfig `mapstructure:"optimization"`
Storage StorageConfig `mapstructure:"storage"`
API APIConfig `mapstructure:"api"`
Logging LoggingConfig `mapstructure:"logging"`
Notifications NotificationsConfig `mapstructure:"notifications"`
}
// WordPressConfig holds WordPress API configuration
type WordPressConfig struct {
BaseURL string `mapstructure:"base_url"`
Username string `mapstructure:"username"`
AppPassword string `mapstructure:"app_password"`
Timeout time.Duration `mapstructure:"timeout"`
}
// ClaudeConfig holds Claude API configuration
type ClaudeConfig struct {
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
MaxTokens int `mapstructure:"max_tokens"`
Temperature float64 `mapstructure:"temperature"`
}
// SchedulerConfig holds scheduling configuration
type SchedulerConfig struct {
Enabled bool `mapstructure:"enabled"`
IntervalHours float64 `mapstructure:"interval_hours"`
Languages []string `mapstructure:"languages"`
}
// OptimizationConfig holds optimization criteria
type OptimizationConfig struct {
MinContentLength int `mapstructure:"min_content_length"`
MaxAgeDays int `mapstructure:"max_age_days"`
MinAgeDays int `mapstructure:"min_age_days"`
CooldownDays int `mapstructure:"reoptimization_cooldown_days"`
MaxInternalLinks int `mapstructure:"max_internal_links"`
FAQMinQuestions int `mapstructure:"faq_min_questions"`
FAQMaxQuestions int `mapstructure:"faq_max_questions"`
}
// StorageConfig holds storage configuration
type StorageConfig struct {
BasePath string `mapstructure:"base_path"`
}
// APIConfig holds REST API server configuration
type APIConfig struct {
Enabled bool `mapstructure:"enabled"`
Port int `mapstructure:"port"`
}
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `mapstructure:"level"` // INFO or DEBUG
Format string `mapstructure:"format"` // json or text
}
// NotificationsConfig holds notification settings
type NotificationsConfig struct {
WebhookURL string `mapstructure:"webhook_url"`
}
// LoadConfig loads configuration from file and environment variables
// Environment variables take precedence over config file values
func LoadConfig(configPath string) (*Config, error) {
// Set config file path
if configPath != "" {
viper.SetConfigFile(configPath)
} else {
// Default to configs/config.yaml
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./configs")
viper.AddConfigPath(".")
}
// Enable environment variable override
viper.AutomaticEnv()
// Bind environment variables to config keys
viper.BindEnv("wordpress.app_password", "WP_APP_PASSWORD")
viper.BindEnv("claude.api_key", "CLAUDE_API_KEY")
viper.BindEnv("scheduler.interval_hours", "OPTIMIZATION_INTERVAL_HOURS")
viper.BindEnv("logging.level", "LOG_LEVEL")
viper.BindEnv("api.port", "API_PORT")
viper.BindEnv("notifications.webhook_url", "WEBHOOK_URL")
// Set defaults
setDefaults()
// Read config file
if err := viper.ReadInConfig(); err != nil {
// Config file not required if all values come from env vars
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
}
// Unmarshal config
var config Config
if err := viper.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validate config
if err := validateConfig(&config); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
// Expand environment variables in config values
config.WordPress.AppPassword = os.ExpandEnv(config.WordPress.AppPassword)
config.Claude.APIKey = os.ExpandEnv(config.Claude.APIKey)
config.Notifications.WebhookURL = os.ExpandEnv(config.Notifications.WebhookURL)
return &config, nil
}
// setDefaults sets default configuration values
func setDefaults() {
// WordPress defaults
viper.SetDefault("wordpress.timeout", 30*time.Second)
// Claude defaults
viper.SetDefault("claude.model", "claude-sonnet-4-20250514")
viper.SetDefault("claude.max_tokens", 4096)
viper.SetDefault("claude.temperature", 0.3)
// Scheduler defaults
viper.SetDefault("scheduler.enabled", true)
viper.SetDefault("scheduler.interval_hours", 2.5)
viper.SetDefault("scheduler.languages", []string{"en", "es"})
// Optimization defaults
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.max_internal_links", 8)
viper.SetDefault("optimization.faq_min_questions", 4)
viper.SetDefault("optimization.faq_max_questions", 6)
// Storage defaults
viper.SetDefault("storage.base_path", "./data/optimizations")
// API defaults
viper.SetDefault("api.enabled", true)
viper.SetDefault("api.port", 8080)
// Logging defaults
viper.SetDefault("logging.level", "INFO")
viper.SetDefault("logging.format", "json")
}
// validateConfig validates the configuration
func validateConfig(cfg *Config) error {
// Validate WordPress config
if cfg.WordPress.BaseURL == "" {
return fmt.Errorf("wordpress.base_url is required")
}
if cfg.WordPress.Username == "" {
return fmt.Errorf("wordpress.username is required")
}
if cfg.WordPress.AppPassword == "" || cfg.WordPress.AppPassword == "${WP_APP_PASSWORD}" {
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")
}
// Validate scheduler config
if cfg.Scheduler.Enabled {
if cfg.Scheduler.IntervalHours <= 0 {
return fmt.Errorf("scheduler.interval_hours must be positive")
}
if len(cfg.Scheduler.Languages) == 0 {
return fmt.Errorf("scheduler.languages must have at least one language")
}
}
// Validate optimization config
if cfg.Optimization.MinContentLength < 0 {
return fmt.Errorf("optimization.min_content_length must be non-negative")
}
if cfg.Optimization.MaxAgeDays < 0 {
return fmt.Errorf("optimization.max_age_days must be non-negative")
}
if cfg.Optimization.MinAgeDays < 0 {
return fmt.Errorf("optimization.min_age_days must be non-negative")
}
if cfg.Optimization.CooldownDays < 0 {
return fmt.Errorf("optimization.reoptimization_cooldown_days must be non-negative")
}
// Validate storage config
if cfg.Storage.BasePath == "" {
return fmt.Errorf("storage.base_path is required")
}
// Validate logging config
if cfg.Logging.Level != "INFO" && cfg.Logging.Level != "DEBUG" {
return fmt.Errorf("logging.level must be INFO or DEBUG")
}
if cfg.Logging.Format != "json" && cfg.Logging.Format != "text" {
return fmt.Errorf("logging.format must be json or text")
}
// Validate API config
if cfg.API.Enabled && (cfg.API.Port < 1 || cfg.API.Port > 65535) {
return fmt.Errorf("api.port must be between 1 and 65535")
}
return nil
}
+63
View File
@@ -0,0 +1,63 @@
package logger
import (
"log/slog"
"os"
)
// Logger wraps the standard slog.Logger with convenience methods
type Logger struct {
*slog.Logger
}
// NewLogger creates a new structured logger with the specified level and format
func NewLogger(level string, format string) *Logger {
var logLevel slog.Level
switch level {
case "DEBUG":
logLevel = slog.LevelDebug
case "INFO":
logLevel = slog.LevelInfo
case "WARN":
logLevel = slog.LevelWarn
case "ERROR":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
opts := &slog.HandlerOptions{
Level: logLevel,
}
var handler slog.Handler
if format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
return &Logger{
Logger: slog.New(handler),
}
}
// NewDefaultLogger creates a logger with INFO level and JSON format
func NewDefaultLogger() *Logger {
return NewLogger("INFO", "json")
}
// WithComponent creates a child logger with a component field
// This is useful for distinguishing logs from different parts of the application
func (l *Logger) WithComponent(component string) *Logger {
return &Logger{
Logger: l.With("component", component),
}
}
// WithFields creates a child logger with additional fields
func (l *Logger) WithFields(args ...any) *Logger {
return &Logger{
Logger: l.With(args...),
}
}
+132
View File
@@ -0,0 +1,132 @@
package scheduler
import (
"context"
"time"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/seo"
)
// Scheduler handles autonomous scheduling of optimization cycles
type Scheduler struct {
optimizer *seo.Optimizer
interval time.Duration
languages []string
currentLangIdx int
stopCh chan struct{}
logger *logger.Logger
}
// NewScheduler creates a new scheduler instance
func NewScheduler(
optimizer *seo.Optimizer,
intervalHours float64,
languages []string,
log *logger.Logger,
) *Scheduler {
if log == nil {
log = logger.NewDefaultLogger()
}
if len(languages) == 0 {
languages = []string{"en", "es"}
}
return &Scheduler{
optimizer: optimizer,
interval: time.Duration(intervalHours * float64(time.Hour)),
languages: languages,
stopCh: make(chan struct{}),
logger: log.WithComponent("scheduler"),
}
}
// Start begins the optimization scheduler loop
// Runs immediately on start, then on configured interval
func (s *Scheduler) Start(ctx context.Context) error {
s.logger.Info("Scheduler started",
"interval", s.interval,
"languages", s.languages,
)
// Run immediately on start
if err := s.runOptimizationCycle(ctx); err != nil {
s.logger.Error("Initial optimization cycle failed", "error", err)
// Don't return error - continue with scheduled runs
}
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.runOptimizationCycle(ctx); err != nil {
s.logger.Error("Optimization cycle failed", "error", err)
// Continue despite error
}
case <-ctx.Done():
s.logger.Info("Scheduler stopped by context")
return ctx.Err()
case <-s.stopCh:
s.logger.Info("Scheduler stopped by signal")
return nil
}
}
}
// Stop gracefully stops the scheduler
func (s *Scheduler) Stop() {
close(s.stopCh)
}
// runOptimizationCycle executes one optimization cycle
// Languages rotate: en → es → en → es...
func (s *Scheduler) runOptimizationCycle(ctx context.Context) error {
// Rotate languages
lang := s.languages[s.currentLangIdx]
s.currentLangIdx = (s.currentLangIdx + 1) % len(s.languages)
s.logger.Info("Running optimization cycle",
"language", lang,
"next_language", s.languages[s.currentLangIdx],
)
startTime := time.Now()
// Run optimization for selected language
err := s.optimizer.OptimizeNextPost(ctx, lang)
duration := time.Since(startTime)
if err != nil {
s.logger.Error("Optimization cycle failed",
"language", lang,
"duration", duration,
"error", err,
)
return err
}
s.logger.Info("Optimization cycle completed",
"language", lang,
"duration", duration,
"next_run_in", s.interval,
)
return nil
}
// GetNextRunTime returns when the next optimization will run
func (s *Scheduler) GetNextRunTime() time.Time {
// This is an approximation - actual time depends on when the last cycle started
return time.Now().Add(s.interval)
}
// GetCurrentLanguage returns the language that will be used in the next cycle
func (s *Scheduler) GetCurrentLanguage() string {
return s.languages[s.currentLangIdx]
}
+458
View File
@@ -0,0 +1,458 @@
package seo
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"seo-optimizer/internal/logger"
"seo-optimizer/pkg/models"
)
// ContentBuilder applies all optimizations to post content
type ContentBuilder struct {
logger *logger.Logger
}
// NewContentBuilder creates a new content builder
func NewContentBuilder(log *logger.Logger) *ContentBuilder {
if log == nil {
log = logger.NewDefaultLogger()
}
return &ContentBuilder{
logger: log.WithComponent("content-builder"),
}
}
// BuildOptimizedContent applies all optimizations and returns the updated content
func (cb *ContentBuilder) BuildOptimizedContent(
original string,
optimization *models.OptimizationResponse,
) (string, error) {
content := original
originalHasFAQ := cb.hasFAQBlock(original)
originalHasBlocks := cb.hasGutenbergBlocks(original)
cb.logger.Info("Building optimized content",
"changes", len(optimization.ContentOptimization.Changes),
"links", len(optimization.ContentOptimization.InternalLinks),
"fact_checks", len(optimization.Validation.FactChecks),
"broken_links", len(optimization.Validation.BrokenLinks),
"faq", optimization.FAQBlock.ShouldCreate,
)
// 1. Apply content changes (keyword injection, heading improvements, etc.)
for i, change := range optimization.ContentOptimization.Changes {
cb.logger.Debug("Applying content change",
"index", i,
"type", change.Type,
"location", change.Location,
)
content = cb.applyChange(content, change)
}
// 2. Apply validation fixes (update outdated info, fix broken links)
for i, factCheck := range optimization.Validation.FactChecks {
if factCheck.Status == "outdated" && factCheck.UpdatedText != "" {
cb.logger.Debug("Applying fact check update",
"index", i,
"claim", truncate(factCheck.Claim, 100),
)
content = cb.applyFactCheckUpdate(content, factCheck)
}
}
for i, brokenLink := range optimization.Validation.BrokenLinks {
cb.logger.Debug("Fixing broken link",
"index", i,
"url", brokenLink.URL,
"action", brokenLink.Action,
)
content = cb.fixBrokenLink(content, brokenLink)
}
// 3. Insert internal links at appropriate positions
for i, link := range optimization.ContentOptimization.InternalLinks {
cb.logger.Debug("Inserting internal link",
"index", i,
"target_post_id", link.TargetPostID,
"anchor", link.AnchorText,
)
content = cb.insertInternalLink(content, link)
}
// 4. Convert to Gutenberg blocks if original content was classic
if !originalHasBlocks {
content = cb.wrapInGutenbergBlocks(content)
}
// 5. Append FAQ block if should_create = true and no existing FAQ block
if optimization.FAQBlock.ShouldCreate && len(optimization.FAQBlock.Questions) > 0 && !originalHasFAQ {
cb.logger.Info("Adding FAQ block",
"questions", len(optimization.FAQBlock.Questions),
)
headingLevel := detectHeadingLevel(content)
faqBlock := cb.buildFAQBlock(&optimization.FAQBlock, headingLevel)
content = content + "\n\n" + faqBlock
} else if originalHasFAQ {
cb.logger.Info("Skipping FAQ block (existing FAQ detected)")
}
cb.logger.Info("Content optimization complete")
return content, nil
}
// applyChange replaces original text with updated text
func (cb *ContentBuilder) applyChange(content string, change models.ContentChange) string {
// Try exact match first
if strings.Contains(content, change.Original) {
return strings.Replace(content, change.Original, change.Updated, 1)
}
// Try normalized match (handle HTML entities, extra whitespace)
normalizedOriginal := normalizeText(change.Original)
normalizedContent := normalizeText(content)
if strings.Contains(normalizedContent, normalizedOriginal) {
// Find position in normalized content
idx := strings.Index(normalizedContent, normalizedOriginal)
if idx >= 0 {
// Find corresponding position in original content
// This is approximate but should work for most cases
before := content[:idx]
after := content[idx+len(change.Original):]
return before + change.Updated + after
}
}
cb.logger.Warn("Could not find exact match for content change",
"original", truncate(change.Original, 100),
"type", change.Type,
)
return content
}
// applyFactCheckUpdate updates outdated technical information
func (cb *ContentBuilder) applyFactCheckUpdate(content string, factCheck models.FactCheck) string {
// Similar to applyChange but specific to fact checks
if strings.Contains(content, factCheck.Claim) {
return strings.Replace(content, factCheck.Claim, factCheck.UpdatedText, 1)
}
cb.logger.Warn("Could not find fact check claim in content",
"claim", truncate(factCheck.Claim, 100),
)
return content
}
// fixBrokenLink fixes or removes broken links
func (cb *ContentBuilder) fixBrokenLink(content string, broken models.BrokenLink) string {
if broken.Action == "replace" && broken.Replacement != "" {
// Replace URL in all link tags
content = strings.ReplaceAll(content, broken.URL, broken.Replacement)
return content
}
if broken.Action == "remove" {
// Remove the entire <a> tag containing this URL
// Pattern: <a [^>]*href="URL"[^>]*>.*?</a>
pattern := fmt.Sprintf(`<a[^>]*href="%s"[^>]*>.*?</a>`, regexp.QuoteMeta(broken.URL))
re := regexp.MustCompile(pattern)
content = re.ReplaceAllString(content, "")
// Also try with escaped quotes
pattern2 := fmt.Sprintf(`<a[^>]*href='%s'[^>]*>.*?</a>`, regexp.QuoteMeta(broken.URL))
re2 := regexp.MustCompile(pattern2)
content = re2.ReplaceAllString(content, "")
}
return content
}
// insertInternalLink inserts an internal link at the appropriate position
func (cb *ContentBuilder) insertInternalLink(content string, link models.InternalLink) string {
// Build the link HTML
linkHTML := fmt.Sprintf(`<a href="/%s/">%s</a>`, link.TargetSlug, link.AnchorText)
// Try to find insertion point based on context
// This is a simplified implementation - could be enhanced with more sophisticated context matching
// If insertion context mentions specific text, try to find and insert after it
if strings.Contains(content, link.InsertionContext) {
// Insert after the context
parts := strings.SplitN(content, link.InsertionContext, 2)
if len(parts) == 2 {
// Find the end of the current sentence/paragraph
endIdx := findSentenceEnd(parts[1])
if endIdx > 0 {
return parts[0] + link.InsertionContext + parts[1][:endIdx] + " " + linkHTML + parts[1][endIdx:]
}
}
}
// Fallback: try to insert the link by replacing the anchor text if it appears in content
if strings.Contains(content, link.AnchorText) && !cb.isInsideTag(content, link.AnchorText) {
return strings.Replace(content, link.AnchorText, linkHTML, 1)
}
cb.logger.Warn("Could not find appropriate insertion point for internal link",
"anchor", link.AnchorText,
"context", truncate(link.InsertionContext, 100),
)
return content
}
// isInsideTag checks if text is already inside an HTML tag
func (cb *ContentBuilder) isInsideTag(content, text string) bool {
idx := strings.Index(content, text)
if idx == -1 {
return false
}
// Check if there's an unclosed < before this position
before := content[:idx]
lastOpen := strings.LastIndex(before, "<")
lastClose := strings.LastIndex(before, ">")
// If last < comes after last >, we're inside a tag
return lastOpen > lastClose
}
// findSentenceEnd finds the end of the current sentence (. ! ?)
func findSentenceEnd(text string) int {
for i, char := range text {
if char == '.' || char == '!' || char == '?' {
return i + 1
}
}
return 0
}
// buildFAQBlock generates a RankMath FAQ Gutenberg block
func (cb *ContentBuilder) buildFAQBlock(faq *models.FAQBlock, headingLevel int) string {
if len(faq.Questions) == 0 {
return ""
}
// Build questions JSON array for Gutenberg comment
questionsJSON, err := json.Marshal(faq.Questions)
if err != nil {
cb.logger.Error("Failed to marshal FAQ questions", "error", err)
return ""
}
// Build the HTML structure for each FAQ item
var faqItems strings.Builder
for _, q := range faq.Questions {
faqItems.WriteString(fmt.Sprintf(`<div class="rank-math-faq-item"><h3 class="rank-math-question">%s</h3><div class="rank-math-answer">%s</div></div>`,
q.Title,
q.Content,
))
}
// Gutenberg block format for RankMath FAQ
// <!-- wp:rank-math/faq-block {"questions":[...]} -->
// <div class="wp-block-rank-math-faq-block">
// <div class="rank-math-faq-item">...</div>
// ...
// </div>
// <!-- /wp:rank-math/faq-block -->
if headingLevel < 1 || headingLevel > 6 {
headingLevel = 2
}
heading := fmt.Sprintf(`<!-- wp:heading {"level":%d} -->
<h%d>Frequently Asked Questions</h%d>
<!-- /wp:heading -->`, headingLevel, headingLevel, headingLevel)
return fmt.Sprintf(`%s
<!-- wp:rank-math/faq-block {"questions":%s} -->
<div class="wp-block-rank-math-faq-block">%s</div>
<!-- /wp:rank-math/faq-block -->`, heading, string(questionsJSON), faqItems.String())
}
func (cb *ContentBuilder) hasFAQBlock(content string) bool {
if strings.Contains(content, "wp:rank-math/faq-block") {
return true
}
if strings.Contains(content, "wp-block-rank-math-faq-block") {
return true
}
return false
}
func (cb *ContentBuilder) hasGutenbergBlocks(content string) bool {
return strings.Contains(content, "<!-- wp:")
}
func (cb *ContentBuilder) wrapInGutenbergBlocks(content string) string {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return content
}
blockRe := regexp.MustCompile(`(?is)(<h[1-6][^>]*>.*?</h[1-6]>|<ul[^>]*>.*?</ul>|<ol[^>]*>.*?</ol>|<p[^>]*>.*?</p>)`)
matches := blockRe.FindAllStringIndex(trimmed, -1)
if len(matches) == 0 {
parts := splitParagraphs(trimmed)
var builder strings.Builder
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if i > 0 && builder.Len() > 0 {
builder.WriteString("\n\n")
}
builder.WriteString(wrapTextAsParagraph(part))
}
return builder.String()
}
var builder strings.Builder
last := 0
for i, match := range matches {
start, end := match[0], match[1]
if start > last {
leading := strings.TrimSpace(trimmed[last:start])
if leading != "" {
if builder.Len() > 0 {
builder.WriteString("\n\n")
}
builder.WriteString(wrapTextAsParagraph(leading))
}
}
block := trimmed[start:end]
if builder.Len() > 0 {
builder.WriteString("\n\n")
}
builder.WriteString(wrapHTMLBlock(block))
last = end
if i == len(matches)-1 && last < len(trimmed) {
trailing := strings.TrimSpace(trimmed[last:])
if trailing != "" {
builder.WriteString("\n\n")
builder.WriteString(wrapTextAsParagraph(trailing))
}
}
}
return builder.String()
}
func wrapTextAsParagraph(text string) string {
return fmt.Sprintf("<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", text)
}
func wrapHTMLBlock(block string) string {
block = strings.TrimSpace(block)
if strings.HasPrefix(strings.ToLower(block), "<p") {
return fmt.Sprintf("<!-- wp:paragraph -->\n%s\n<!-- /wp:paragraph -->", block)
}
if strings.HasPrefix(strings.ToLower(block), "<ul") || strings.HasPrefix(strings.ToLower(block), "<ol") {
return fmt.Sprintf("<!-- wp:list -->\n%s\n<!-- /wp:list -->", block)
}
if strings.HasPrefix(strings.ToLower(block), "<h") {
level := extractHeadingLevel(block)
if level > 0 {
return fmt.Sprintf("<!-- wp:heading {\"level\":%d} -->\n%s\n<!-- /wp:heading -->", level, block)
}
return fmt.Sprintf("<!-- wp:heading -->\n%s\n<!-- /wp:heading -->", block)
}
return fmt.Sprintf("<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", block)
}
func extractHeadingLevel(block string) int {
re := regexp.MustCompile(`(?i)<h([1-6])[^>]*>`)
matches := re.FindStringSubmatch(block)
if len(matches) != 2 {
return 0
}
switch matches[1] {
case "1":
return 1
case "2":
return 2
case "3":
return 3
case "4":
return 4
case "5":
return 5
case "6":
return 6
default:
return 0
}
}
func detectHeadingLevel(content string) int {
blockHeadingRe := regexp.MustCompile(`<!--\s*wp:heading\s*(\{[^}]*\})?\s*-->`)
blockHeadingLevels := regexp.MustCompile(`"level"\s*:\s*(\d)`)
if match := blockHeadingRe.FindString(content); match != "" {
levelMatch := blockHeadingLevels.FindStringSubmatch(match)
if len(levelMatch) == 2 {
if level, err := strconv.Atoi(levelMatch[1]); err == nil {
return level
}
}
}
htmlHeadingRe := regexp.MustCompile(`(?i)<h([1-6])[^>]*>`)
if match := htmlHeadingRe.FindStringSubmatch(content); len(match) == 2 {
if level, err := strconv.Atoi(match[1]); err == nil {
return level
}
}
return 2
}
func splitParagraphs(content string) []string {
re := regexp.MustCompile(`\r?\n\s*\r?\n`)
return re.Split(content, -1)
}
// normalizeText normalizes text for comparison (handles whitespace, HTML entities)
func normalizeText(text string) string {
// Normalize whitespace
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
text = strings.TrimSpace(text)
// Decode common HTML entities
text = strings.ReplaceAll(text, "&nbsp;", " ")
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
return text
}
// truncate truncates a string to max length
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
+76
View File
@@ -0,0 +1,76 @@
package seo
import (
"strings"
"testing"
"seo-optimizer/pkg/models"
)
func TestHasFAQBlock(t *testing.T) {
builder := NewContentBuilder(nil)
content := `<!-- wp:rank-math/faq-block {"questions":[]} --><div class="wp-block-rank-math-faq-block"></div><!-- /wp:rank-math/faq-block -->`
if !builder.hasFAQBlock(content) {
t.Fatalf("expected FAQ block to be detected")
}
}
func TestBuildOptimizedContentSkipsExistingFAQ(t *testing.T) {
builder := NewContentBuilder(nil)
original := `<!-- wp:rank-math/faq-block {"questions":[]} --><div class="wp-block-rank-math-faq-block"></div><!-- /wp:rank-math/faq-block -->`
optimization := &models.OptimizationResponse{
SEOMeta: models.SEOMeta{},
ContentOptimization: models.ContentOptimization{
Changes: []models.ContentChange{},
InternalLinks: []models.InternalLink{},
},
FAQBlock: models.FAQBlock{
ShouldCreate: true,
Questions: []models.FAQQuestion{
{ID: "faq-question-1", Title: "Q1", Content: "A1", Visible: true},
},
},
Validation: models.Validation{},
Summary: "",
}
result, err := builder.BuildOptimizedContent(original, optimization)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Count(result, "wp:rank-math/faq-block") != 2 {
t.Fatalf("expected single FAQ block markers, got %d", strings.Count(result, "wp:rank-math/faq-block"))
}
}
func TestWrapInGutenbergBlocks(t *testing.T) {
builder := NewContentBuilder(nil)
content := "Hello world\n\nSecond paragraph"
wrapped := builder.wrapInGutenbergBlocks(content)
if !strings.Contains(wrapped, "<!-- wp:paragraph -->") {
t.Fatalf("expected paragraph blocks to be added")
}
if count := strings.Count(wrapped, "<!-- wp:paragraph -->"); count != 2 {
t.Fatalf("expected 2 paragraph blocks, got %d", count)
}
}
func TestWrapInGutenbergBlocksHandlesHeadingsAndLists(t *testing.T) {
builder := NewContentBuilder(nil)
content := "<h2>Title</h2>\n\n<ul><li>One</li><li>Two</li></ul>\n\n<p>Para</p>"
wrapped := builder.wrapInGutenbergBlocks(content)
if !strings.Contains(wrapped, "<!-- wp:heading {\"level\":2} -->") {
t.Fatalf("expected heading block wrapper")
}
if !strings.Contains(wrapped, "<!-- wp:list -->") {
t.Fatalf("expected list block wrapper")
}
if count := strings.Count(wrapped, "<!-- wp:paragraph -->"); count != 1 {
t.Fatalf("expected 1 paragraph block, got %d", count)
}
}
+574
View File
@@ -0,0 +1,574 @@
package seo
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"seo-optimizer/internal/agent"
"seo-optimizer/internal/config"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
"seo-optimizer/pkg/models"
)
// Optimizer coordinates the entire SEO optimization workflow
type Optimizer struct {
wpClient *wordpress.Client
claudeClient *agent.ClaudeClient
contentBuilder *ContentBuilder
selector *wordpress.PostSelector
storage *storage.OptimizationStorage
config *config.Config
logger *logger.Logger
}
// SkipError indicates a deliberate skip (not a failure).
type SkipError struct {
Reason string
}
func (e *SkipError) Error() string {
return e.Reason
}
// NewOptimizer creates a new optimizer instance
func NewOptimizer(
wpClient *wordpress.Client,
claudeClient *agent.ClaudeClient,
store *storage.OptimizationStorage,
cfg *config.Config,
log *logger.Logger,
) *Optimizer {
if log == nil {
log = logger.NewDefaultLogger()
}
return &Optimizer{
wpClient: wpClient,
claudeClient: claudeClient,
contentBuilder: NewContentBuilder(log),
selector: wordpress.NewPostSelector(wpClient, log),
storage: store,
config: cfg,
logger: log.WithComponent("optimizer"),
}
}
// 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)
startTime := time.Now()
// 1. Select next post
criteria := wordpress.SelectionCriteria{
Language: language,
MinContentLength: o.config.Optimization.MinContentLength,
MinAgeDays: o.config.Optimization.MinAgeDays,
MaxAgeDays: o.config.Optimization.MaxAgeDays,
CooldownDays: o.config.Optimization.CooldownDays,
}
post, err := o.selector.SelectNextPost(ctx, criteria)
if err != nil {
return fmt.Errorf("post selection failed: %w", err)
}
if post == nil {
o.logger.Info("No eligible posts found for optimization", "language", language)
return nil
}
o.logger.Info("Selected post for optimization",
"post_id", post.ID,
"title", post.Title.Rendered,
"slug", post.Slug,
"modified", post.Modified,
)
// 2. Gather context for Claude
internalPosts, err := o.wpClient.GetInternalPosts(ctx, language)
if err != nil {
return fmt.Errorf("failed to fetch internal posts: %w", err)
}
o.logger.Debug("Retrieved internal posts for linking",
"count", len(internalPosts),
)
// Get categories and tags for context
categories, err := o.wpClient.GetCategories(ctx)
if err != nil {
o.logger.Warn("Failed to fetch categories", "error", err)
categories = []wordpress.Category{}
}
tags, err := o.wpClient.GetTags(ctx)
if err != nil {
o.logger.Warn("Failed to fetch tags", "error", err)
tags = []wordpress.Tag{}
}
// Build category and tag maps
categoryMap := make(map[int]string)
for _, cat := range categories {
categoryMap[cat.ID] = cat.Name
}
tagMap := make(map[int]string)
for _, tag := range tags {
tagMap[tag.ID] = tag.Name
}
// 3. Call Claude for optimization
o.logger.Info("Calling Claude for optimization")
optimization, err := o.claudeClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap)
if err != nil {
return fmt.Errorf("claude optimization failed: %w", err)
}
// Skip if the post is too recent and no modifications were required
if o.shouldSkipRecentNoChanges(post, optimization) {
o.logger.Info("Skipping optimization for recent post with no changes",
"post_id", post.ID,
"modified", post.Modified,
)
if err := o.saveSkippedRecord(post, language, "no_changes_recent", optimization); err != nil {
o.logger.Warn("Failed to save skipped optimization record", "error", err)
}
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
o.logger.Warn("Failed to mark post as optimized", "error", err)
}
return nil
}
// 4. Build optimized content
o.logger.Info("Building optimized content")
optimizedContent, err := o.contentBuilder.BuildOptimizedContent(
post.Content.Rendered,
optimization,
)
if err != nil {
return fmt.Errorf("content building failed: %w", err)
}
// 5. Create draft revision in WordPress
o.logger.Info("Creating draft revision in WordPress")
updates := wordpress.PostUpdate{
Status: "draft",
Title: optimization.SEOMeta.Title,
Content: optimizedContent,
Excerpt: optimization.SEOMeta.Description,
Meta: map[string]interface{}{
// Store SEO meta fields (RankMath)
wordpress.MetaRankMathTitle: optimization.SEOMeta.Title,
wordpress.MetaRankMathDescription: optimization.SEOMeta.Description,
// Store optimization metadata
wordpress.MetaSEOOptimizedAt: time.Now().Unix(),
wordpress.MetaSEOOptimizationSummary: optimization.Summary,
// Store full optimization data for reference
wordpress.MetaSEOOptimizationData: o.serializeOptimizationData(optimization),
},
}
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,
)
// 6. Save optimization record to storage
record := &storage.OptimizationRecord{
PostID: post.ID,
OriginalPostID: post.ID,
DraftPostID: draftPostID,
PostTitle: post.Title.Rendered,
Language: language,
OptimizedAt: time.Now(),
Status: "pending",
Optimization: optimization,
}
if err := o.storage.Save(record); err != nil {
o.logger.Error("Failed to save optimization record", "error", err)
}
// 7. Mark as optimized (prevent re-optimization too soon)
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
o.logger.Warn("Failed to mark post as optimized", "error", err)
}
duration := time.Since(startTime)
// 8. Log success with summary
o.logger.Info("Optimization completed successfully",
"post_id", post.ID,
"title", post.Title.Rendered,
"language", language,
"duration", duration,
"changes", len(optimization.ContentOptimization.Changes),
"internal_links", len(optimization.ContentOptimization.InternalLinks),
"faq_created", optimization.FAQBlock.ShouldCreate,
"faq_questions", len(optimization.FAQBlock.Questions),
"fact_checks", len(optimization.Validation.FactChecks),
"broken_links_fixed", len(optimization.Validation.BrokenLinks),
)
o.logger.Info("Optimization summary",
"post_id", post.ID,
"summary", optimization.Summary,
)
// 9. Send webhook notification (if configured)
if o.config.Notifications.WebhookURL != "" {
go o.sendWebhookNotification(post, optimization, duration)
}
return nil
}
// OptimizeSpecificPost allows manual optimization of a specific post via API
func (o *Optimizer) OptimizeSpecificPost(ctx context.Context, postID int, language string) error {
o.logger.Info("Starting specific post optimization",
"post_id", postID,
"language", language,
)
startTime := time.Now()
// 1. Fetch the specific post
post, err := o.wpClient.GetPost(ctx, postID, language)
if err != nil {
return fmt.Errorf("failed to fetch post: %w", err)
}
if post.Status != "publish" {
return fmt.Errorf("post is not published (status: %s)", post.Status)
}
if o.wasRecentlyOptimized(post, o.config.Optimization.CooldownDays) {
reason := "post optimized within cooldown window"
o.logger.Info("Skipping optimization", "post_id", post.ID, "reason", reason)
if err := o.saveSkippedRecord(post, language, "recently_optimized", nil); err != nil {
o.logger.Warn("Failed to save skipped optimization record", "error", err)
}
return &SkipError{Reason: reason}
}
o.logger.Info("Retrieved post",
"title", post.Title.Rendered,
"slug", post.Slug,
)
// 2. Gather context for Claude
internalPosts, err := o.wpClient.GetInternalPosts(ctx, language)
if err != nil {
return fmt.Errorf("failed to fetch internal posts: %w", err)
}
// Get categories and tags
categories, err := o.wpClient.GetCategories(ctx)
if err != nil {
categories = []wordpress.Category{}
}
tags, err := o.wpClient.GetTags(ctx)
if err != nil {
tags = []wordpress.Tag{}
}
categoryMap := make(map[int]string)
for _, cat := range categories {
categoryMap[cat.ID] = cat.Name
}
tagMap := make(map[int]string)
for _, tag := range tags {
tagMap[tag.ID] = tag.Name
}
// 3. Call Claude for optimization
optimization, err := o.claudeClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap)
if err != nil {
return fmt.Errorf("claude optimization failed: %w", err)
}
// Skip if the post is too recent and no modifications were required
if o.shouldSkipRecentNoChanges(post, optimization) {
reason := "post too recent and no changes required"
o.logger.Info("Skipping optimization", "post_id", post.ID, "reason", reason)
if err := o.saveSkippedRecord(post, language, "no_changes_recent", optimization); err != nil {
o.logger.Warn("Failed to save skipped optimization record", "error", err)
}
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
o.logger.Warn("Failed to mark post as optimized", "error", err)
}
return &SkipError{Reason: reason}
}
// 4. Build optimized content
optimizedContent, err := o.contentBuilder.BuildOptimizedContent(
post.Content.Rendered,
optimization,
)
if err != nil {
return fmt.Errorf("content building failed: %w", err)
}
// 5. Create draft revision
updates := wordpress.PostUpdate{
Status: "draft",
Title: optimization.SEOMeta.Title,
Content: optimizedContent,
Excerpt: optimization.SEOMeta.Description,
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: optimization.SEOMeta.Title,
wordpress.MetaRankMathDescription: optimization.SEOMeta.Description,
wordpress.MetaSEOOptimizedAt: time.Now().Unix(),
wordpress.MetaSEOOptimizationSummary: optimization.Summary,
wordpress.MetaSEOOptimizationData: o.serializeOptimizationData(optimization),
},
}
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,
)
// 6. Save optimization record to storage
record := &storage.OptimizationRecord{
PostID: post.ID,
OriginalPostID: post.ID,
DraftPostID: draftPostID,
PostTitle: post.Title.Rendered,
Language: language,
OptimizedAt: time.Now(),
Status: "pending",
Optimization: optimization,
}
if err := o.storage.Save(record); err != nil {
o.logger.Error("Failed to save optimization record", "error", err)
}
// 7. Mark as optimized
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
o.logger.Warn("Failed to mark post as optimized", "error", err)
}
duration := time.Since(startTime)
o.logger.Info("Specific post optimization completed",
"post_id", postID,
"duration", duration,
)
// Send webhook if configured
if o.config.Notifications.WebhookURL != "" {
go o.sendWebhookNotification(post, optimization, duration)
}
return nil
}
func (o *Optimizer) shouldSkipRecentNoChanges(post *wordpress.Post, optimization *models.OptimizationResponse) bool {
if o.config.Optimization.MinAgeDays <= 0 {
return false
}
postTime := o.getPostModifiedTime(post)
if postTime.IsZero() {
return false
}
minAgeCutoff := time.Now().AddDate(0, 0, -o.config.Optimization.MinAgeDays)
if postTime.Before(minAgeCutoff) {
return false
}
return o.isNoChangeOptimization(post, optimization)
}
func (o *Optimizer) isNoChangeOptimization(post *wordpress.Post, optimization *models.OptimizationResponse) bool {
if optimization == nil {
return false
}
changesEmpty := len(optimization.ContentOptimization.Changes) == 0
linksEmpty := len(optimization.ContentOptimization.InternalLinks) == 0
faqEmpty := !optimization.FAQBlock.ShouldCreate || len(optimization.FAQBlock.Questions) == 0
currentTitle := o.getMetaString(post.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
if currentTitle == "" {
currentTitle = post.Title.Rendered
}
currentDescription := o.getMetaString(post.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
metaUnchanged := strings.TrimSpace(optimization.SEOMeta.Title) == strings.TrimSpace(currentTitle) &&
strings.TrimSpace(optimization.SEOMeta.Description) == strings.TrimSpace(currentDescription)
return changesEmpty && linksEmpty && faqEmpty && metaUnchanged
}
func (o *Optimizer) getMetaString(meta map[string]interface{}, keys ...string) string {
if meta == nil {
return ""
}
for _, key := range keys {
if value, ok := meta[key]; ok && value != nil {
if str, ok := value.(string); ok && str != "" {
return str
}
}
}
return ""
}
func (o *Optimizer) getPostModifiedTime(post *wordpress.Post) time.Time {
if post == nil {
return time.Time{}
}
if !post.Modified.Time.IsZero() {
return post.Modified.Time
}
if !post.Date.Time.IsZero() {
return post.Date.Time
}
return time.Time{}
}
func (o *Optimizer) wasRecentlyOptimized(post *wordpress.Post, cooldownDays int) bool {
if post == nil || post.Meta == nil || cooldownDays <= 0 {
return false
}
optimizedAtValue, exists := post.Meta[wordpress.MetaSEOOptimizedAt]
if !exists || optimizedAtValue == nil {
return false
}
var optimizedAtUnix int64
switch v := optimizedAtValue.(type) {
case float64:
optimizedAtUnix = int64(v)
case int64:
optimizedAtUnix = v
case int:
optimizedAtUnix = int64(v)
case string:
fmt.Sscanf(v, "%d", &optimizedAtUnix)
default:
return false
}
if optimizedAtUnix == 0 {
return false
}
optimizedAt := time.Unix(optimizedAtUnix, 0)
cooldownUntil := optimizedAt.AddDate(0, 0, cooldownDays)
return time.Now().Before(cooldownUntil)
}
func (o *Optimizer) saveSkippedRecord(
post *wordpress.Post,
language string,
reason string,
optimization *models.OptimizationResponse,
) error {
if post == nil {
return fmt.Errorf("post is nil")
}
record := &storage.OptimizationRecord{
PostID: post.ID,
OriginalPostID: post.ID,
DraftPostID: 0,
PostTitle: post.Title.Rendered,
Language: language,
OptimizedAt: time.Now(),
Status: "skipped",
Optimization: optimization,
SkipReason: reason,
}
return o.storage.Save(record)
}
// serializeOptimizationData converts optimization response to JSON string for storage
func (o *Optimizer) serializeOptimizationData(optimization *models.OptimizationResponse) string {
data, err := json.Marshal(optimization)
if err != nil {
o.logger.Error("Failed to serialize optimization data", "error", err)
return ""
}
return string(data)
}
// sendWebhookNotification sends a notification webhook with optimization results
func (o *Optimizer) sendWebhookNotification(
post *wordpress.Post,
optimization *models.OptimizationResponse,
duration time.Duration,
) {
payload := map[string]interface{}{
"post_id": post.ID,
"title": post.Title.Rendered,
"slug": post.Slug,
"link": post.Link,
"language": post.Lang,
"seo_title": optimization.SEOMeta.Title,
"seo_desc": optimization.SEOMeta.Description,
"changes": len(optimization.ContentOptimization.Changes),
"links": len(optimization.ContentOptimization.InternalLinks),
"faq_created": optimization.FAQBlock.ShouldCreate,
"faq_questions": len(optimization.FAQBlock.Questions),
"summary": optimization.Summary,
"duration_seconds": duration.Seconds(),
"timestamp": time.Now().Format(time.RFC3339),
}
jsonData, err := json.Marshal(payload)
if err != nil {
o.logger.Error("Failed to marshal webhook payload", "error", err)
return
}
resp, err := http.Post(o.config.Notifications.WebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
o.logger.Error("Failed to send webhook", "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
o.logger.Debug("Webhook sent successfully", "status", resp.StatusCode)
} else {
o.logger.Warn("Webhook returned non-2xx status", "status", resp.StatusCode)
}
}
+228
View File
@@ -0,0 +1,228 @@
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"seo-optimizer/internal/logger"
"seo-optimizer/pkg/models"
)
// OptimizationRecord represents a stored optimization result
type OptimizationRecord struct {
PostID int `json:"post_id"`
OriginalPostID int `json:"original_post_id"` // For draft posts
DraftPostID int `json:"draft_post_id"`
PostTitle string `json:"post_title"`
Language string `json:"language"`
OptimizedAt time.Time `json:"optimized_at"`
Status string `json:"status"` // "pending", "applied", "rejected", "skipped"
Optimization *models.OptimizationResponse `json:"optimization"`
SkipReason string `json:"skip_reason,omitempty"`
AppliedAt *time.Time `json:"applied_at,omitempty"`
}
// OptimizationStorage manages file-based storage of optimization results
type OptimizationStorage struct {
basePath string
logger *logger.Logger
}
// NewOptimizationStorage creates a new optimization storage instance
func NewOptimizationStorage(basePath string, log *logger.Logger) *OptimizationStorage {
if log == nil {
log = logger.NewDefaultLogger()
}
return &OptimizationStorage{
basePath: basePath,
logger: log.WithComponent("storage"),
}
}
// Initialize creates the storage directory structure
func (s *OptimizationStorage) Initialize() error {
dirs := []string{
s.basePath,
filepath.Join(s.basePath, "pending"),
filepath.Join(s.basePath, "applied"),
filepath.Join(s.basePath, "rejected"),
filepath.Join(s.basePath, "skipped"),
}
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("Storage initialized", "base_path", s.basePath)
return nil
}
// Save stores an optimization record
func (s *OptimizationStorage) Save(record *OptimizationRecord) error {
if record.Status == "" {
record.Status = "pending"
}
// Determine the directory based on status
dir := filepath.Join(s.basePath, record.Status)
filename := fmt.Sprintf("%d_%d.json", record.OriginalPostID, record.OptimizedAt.Unix())
filePath := filepath.Join(dir, filename)
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal record: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
s.logger.Debug("Optimization record saved",
"post_id", record.OriginalPostID,
"status", record.Status,
"file", filePath,
)
return nil
}
// Get retrieves an optimization record by original post ID
func (s *OptimizationStorage) Get(originalPostID int) (*OptimizationRecord, error) {
// Search in all status directories
statuses := []string{"pending", "applied", "rejected", "skipped"}
for _, status := range statuses {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
continue
}
// Find the most recent file for this post ID
for i := len(files) - 1; i >= 0; i-- {
file := files[i]
if file.IsDir() {
continue
}
// Check if filename starts with the post ID
var filePostID int
var timestamp int64
if _, err := fmt.Sscanf(file.Name(), "%d_%d.json", &filePostID, &timestamp); err != nil {
continue
}
if filePostID == originalPostID {
filePath := filepath.Join(dir, file.Name())
return s.readRecord(filePath)
}
}
}
return nil, fmt.Errorf("optimization record not found for post %d", originalPostID)
}
// ListPending returns all pending optimization records
func (s *OptimizationStorage) ListPending() ([]*OptimizationRecord, error) {
return s.listByStatus("pending")
}
// ListApplied returns all applied optimization records
func (s *OptimizationStorage) ListApplied() ([]*OptimizationRecord, error) {
return s.listByStatus("applied")
}
// listByStatus returns all records with the specified status
func (s *OptimizationStorage) listByStatus(status string) ([]*OptimizationRecord, error) {
dir := filepath.Join(s.basePath, status)
files, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []*OptimizationRecord{}, nil
}
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var records []*OptimizationRecord
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
}
records = append(records, record)
}
// Sort by optimization date (newest first)
sort.Slice(records, func(i, j int) bool {
return records[i].OptimizedAt.After(records[j].OptimizedAt)
})
return records, nil
}
// UpdateStatus moves a record from one status to another
func (s *OptimizationStorage) UpdateStatus(originalPostID int, newStatus string) error {
// Get the current record
record, err := s.Get(originalPostID)
if err != nil {
return err
}
// Delete old file
oldDir := filepath.Join(s.basePath, record.Status)
oldFilename := fmt.Sprintf("%d_%d.json", record.OriginalPostID, record.OptimizedAt.Unix())
oldPath := filepath.Join(oldDir, oldFilename)
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to remove old record", "path", oldPath, "error", err)
}
// Update status
record.Status = newStatus
if newStatus == "applied" {
now := time.Now()
record.AppliedAt = &now
}
// Save to new location
return s.Save(record)
}
// GetLastOptimizationDate returns the last optimization date for a post
func (s *OptimizationStorage) GetLastOptimizationDate(postID int) (*time.Time, error) {
record, err := s.Get(postID)
if err != nil {
return nil, err
}
return &record.OptimizedAt, nil
}
// readRecord reads and unmarshals a record from a file
func (s *OptimizationStorage) readRecord(filePath string) (*OptimizationRecord, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var record OptimizationRecord
if err := json.Unmarshal(data, &record); err != nil {
return nil, fmt.Errorf("failed to unmarshal record: %w", err)
}
return &record, nil
}
+625
View File
@@ -0,0 +1,625 @@
package wordpress
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"seo-optimizer/internal/logger"
)
// Client is a WordPress REST API client with Application Password authentication
type Client struct {
baseURL string
username string
appPassword string
authHeader string
httpClient *http.Client
logger *logger.Logger
}
// NewClient creates a new WordPress REST API client
func NewClient(baseURL, username, appPassword string, timeout time.Duration, log *logger.Logger) *Client {
// Ensure base URL doesn't have trailing slash
baseURL = strings.TrimRight(baseURL, "/")
// Create Basic Auth header
auth := username + ":" + appPassword
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
if log == nil {
log = logger.NewDefaultLogger()
}
return &Client{
baseURL: baseURL,
username: username,
appPassword: appPassword,
authHeader: authHeader,
httpClient: &http.Client{
Timeout: timeout,
},
logger: log.WithComponent("wordpress"),
}
}
// GetPost retrieves a single post by ID
func (c *Client) GetPost(ctx context.Context, postID int, lang string) (*Post, error) {
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", postID)
params := url.Values{}
if lang != "" {
params.Set("lang", lang)
}
var post Post
if err := c.get(ctx, endpoint, params, &post); err != nil {
return nil, fmt.Errorf("failed to get post %d: %w", postID, err)
}
return &post, nil
}
// GetPostForEdit retrieves a single post by ID with edit context (includes raw content).
func (c *Client) GetPostForEdit(ctx context.Context, postID int, lang string) (*Post, error) {
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", postID)
params := url.Values{}
params.Set("context", "edit")
if lang != "" {
params.Set("lang", lang)
}
var post Post
if err := c.get(ctx, endpoint, params, &post); err != nil {
return nil, fmt.Errorf("failed to get post %d for edit: %w", postID, err)
}
return &post, nil
}
// ListPosts retrieves a list of posts with the specified parameters
func (c *Client) ListPosts(ctx context.Context, params ListParams) ([]*Post, error) {
endpoint := "/wp-json/wp/v2/posts"
queryParams := url.Values{}
if params.Lang != "" {
queryParams.Set("lang", params.Lang)
}
if params.Status != "" {
queryParams.Set("status", params.Status)
}
if params.OrderBy != "" {
queryParams.Set("orderby", params.OrderBy)
}
if params.Order != "" {
queryParams.Set("order", params.Order)
}
if params.PerPage > 0 {
queryParams.Set("per_page", strconv.Itoa(params.PerPage))
}
if params.Page > 0 {
queryParams.Set("page", strconv.Itoa(params.Page))
}
if params.After != nil {
queryParams.Set("after", params.After.Format(time.RFC3339))
}
if params.Before != nil {
queryParams.Set("before", params.Before.Format(time.RFC3339))
}
if params.Search != "" {
queryParams.Set("search", params.Search)
}
if len(params.Categories) > 0 {
catIDs := make([]string, len(params.Categories))
for i, catID := range params.Categories {
catIDs[i] = strconv.Itoa(catID)
}
queryParams.Set("categories", strings.Join(catIDs, ","))
}
if len(params.Tags) > 0 {
tagIDs := make([]string, len(params.Tags))
for i, tagID := range params.Tags {
tagIDs[i] = strconv.Itoa(tagID)
}
queryParams.Set("tags", strings.Join(tagIDs, ","))
}
var posts []*Post
if err := c.get(ctx, endpoint, queryParams, &posts); err != nil {
return nil, fmt.Errorf("failed to list posts: %w", err)
}
return posts, nil
}
// GetPostMeta retrieves a specific meta field value for a post
func (c *Client) GetPostMeta(ctx context.Context, postID int, metaKey string) (interface{}, error) {
post, err := c.GetPost(ctx, postID, "")
if err != nil {
return nil, err
}
if post.Meta == nil {
return nil, nil
}
return post.Meta[metaKey], nil
}
// UpdatePostMeta updates a specific meta field for a post
func (c *Client) UpdatePostMeta(ctx context.Context, postID int, metaKey string, value interface{}) error {
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", postID)
update := map[string]interface{}{
"meta": map[string]interface{}{
metaKey: value,
},
}
if err := c.post(ctx, endpoint, update, nil); err != nil {
return fmt.Errorf("failed to update post meta %s: %w", metaKey, err)
}
return nil
}
// CreateDraftRevision creates a linked draft copy of an existing post
// This allows reviewing changes without unpublishing the original
// The draft is linked via meta field _draft_of_post_id
func (c *Client) CreateDraftRevision(ctx context.Context, postID int, updates PostUpdate) (int, error) {
// First, get the original post to copy metadata
originalPost, err := c.GetPost(ctx, postID, "")
if err != nil {
return 0, fmt.Errorf("failed to get original post: %w", err)
}
// Create a new post with draft status
endpoint := "/wp-json/wp/v2/posts"
// Build the draft post payload
draftPost := map[string]interface{}{
"status": "draft",
"title": updates.Title,
"content": updates.Content,
"excerpt": updates.Excerpt,
"lang": originalPost.Lang, // Preserve language
"meta": updates.Meta,
}
// Add link to original post in meta
if draftPost["meta"] == nil {
draftPost["meta"] = make(map[string]interface{})
}
metaMap := draftPost["meta"].(map[string]interface{})
metaMap[MetaDraftOfPostID] = postID
c.logger.Info("Creating linked draft copy",
"original_post_id", postID,
"has_title", updates.Title != "",
"has_content", updates.Content != "",
"has_excerpt", updates.Excerpt != "",
"meta_fields", len(updates.Meta),
)
var result Post
if err := c.post(ctx, endpoint, draftPost, &result); err != nil {
return 0, fmt.Errorf("failed to create draft copy: %w", err)
}
c.logger.Info("Draft copy created successfully",
"draft_post_id", result.ID,
"original_post_id", postID,
)
return result.ID, 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
draftPost, err := c.GetPostForEdit(ctx, draftPostID, "")
if err != nil {
return fmt.Errorf("failed to get draft post: %w", err)
}
// Verify this is actually a draft
if draftPost.Status != "draft" {
return fmt.Errorf("post %d is not a draft (status: %s)", draftPostID, draftPost.Status)
}
// Get the original post ID from meta
originalPostIDRaw, exists := draftPost.Meta[MetaDraftOfPostID]
if !exists {
return fmt.Errorf("draft post %d is not linked to an original post", draftPostID)
}
// Convert to int (might be float64 from JSON)
var originalPostID int
switch v := originalPostIDRaw.(type) {
case float64:
originalPostID = int(v)
case int:
originalPostID = v
default:
return fmt.Errorf("invalid original post ID type: %T", originalPostIDRaw)
}
c.logger.Info("Applying draft to original post",
"draft_post_id", draftPostID,
"original_post_id", originalPostID,
)
// Update the original post with draft content
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", originalPostID)
update := map[string]interface{}{
"title": rawOrRendered(draftPost.Title),
"content": rawOrRendered(draftPost.Content),
"excerpt": rawOrRendered(draftPost.Excerpt),
"status": "publish", // Keep it published
"meta": draftPost.Meta,
}
// Remove the draft link meta from the update
if metaMap, ok := update["meta"].(map[string]interface{}); ok {
delete(metaMap, MetaDraftOfPostID)
}
if err := c.post(ctx, endpoint, update, nil); err != nil {
return fmt.Errorf("failed to update original post: %w", err)
}
c.logger.Info("Original post updated successfully, deleting draft",
"original_post_id", originalPostID,
)
// Delete the draft post
deleteEndpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d?force=true", draftPostID)
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+deleteEndpoint, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
resp, err := c.doWithRetry(req)
if err != nil {
return fmt.Errorf("failed to delete draft post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to delete draft post: status %d, body: %s", resp.StatusCode, string(body))
}
c.logger.Info("Draft post deleted successfully",
"draft_post_id", draftPostID,
)
return nil
}
// ApplyDraftToOriginalWithID applies a draft to a specific original post ID.
// This is used when the draft link meta is missing but the mapping is known.
func (c *Client) ApplyDraftToOriginalWithID(ctx context.Context, draftPostID, originalPostID int) error {
draftPost, err := c.GetPostForEdit(ctx, draftPostID, "")
if err != nil {
return fmt.Errorf("failed to get draft post: %w", err)
}
if draftPost.Status != "draft" {
return fmt.Errorf("post %d is not a draft (status: %s)", draftPostID, draftPost.Status)
}
c.logger.Info("Applying draft to original post (explicit original ID)",
"draft_post_id", draftPostID,
"original_post_id", originalPostID,
)
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", originalPostID)
update := map[string]interface{}{
"title": rawOrRendered(draftPost.Title),
"content": rawOrRendered(draftPost.Content),
"excerpt": rawOrRendered(draftPost.Excerpt),
"status": "publish",
"meta": draftPost.Meta,
}
if metaMap, ok := update["meta"].(map[string]interface{}); ok {
delete(metaMap, MetaDraftOfPostID)
}
if err := c.post(ctx, endpoint, update, nil); err != nil {
return fmt.Errorf("failed to update original post: %w", err)
}
c.logger.Info("Original post updated successfully, deleting draft",
"original_post_id", originalPostID,
)
return c.DeleteDraft(ctx, draftPostID)
}
func rawOrRendered(field Rendered) string {
if strings.TrimSpace(field.Raw) != "" {
return field.Raw
}
return field.Rendered
}
// DeleteDraft deletes a draft post without applying it
func (c *Client) DeleteDraft(ctx context.Context, draftPostID int) error {
draftPost, err := c.GetPost(ctx, draftPostID, "")
if err != nil {
return fmt.Errorf("failed to get draft post: %w", err)
}
if draftPost.Status != "draft" {
return fmt.Errorf("post %d is not a draft (status: %s)", draftPostID, draftPost.Status)
}
deleteEndpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d?force=true", draftPostID)
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+deleteEndpoint, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
resp, err := c.doWithRetry(req)
if err != nil {
return fmt.Errorf("failed to delete draft post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to delete draft post: status %d, body: %s", resp.StatusCode, string(body))
}
c.logger.Info("Draft post deleted successfully",
"draft_post_id", draftPostID,
)
return nil
}
// GetInternalPosts retrieves all published posts for internal linking suggestions
func (c *Client) GetInternalPosts(ctx context.Context, lang string) ([]*InternalPostReference, error) {
params := ListParams{
Lang: lang,
Status: "publish",
PerPage: 100, // Get max allowed per request
OrderBy: "date",
Order: "desc",
}
var allPosts []*InternalPostReference
page := 1
// Fetch all posts (paginated)
for {
params.Page = page
posts, err := c.ListPosts(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to fetch internal posts: %w", err)
}
if len(posts) == 0 {
break
}
// Convert to internal post references
for _, post := range posts {
ref := &InternalPostReference{
ID: post.ID,
Title: post.Title.Rendered,
Slug: post.Slug,
Link: post.Link,
Categories: post.Categories,
Tags: post.Tags,
Excerpt: post.Excerpt.Rendered,
}
allPosts = append(allPosts, ref)
}
// If we got less than per_page results, we're on the last page
if len(posts) < params.PerPage {
break
}
page++
// Safety limit to prevent infinite loops
if page > 10 {
c.logger.Warn("Reached page limit for internal posts", "pages", page)
break
}
}
c.logger.Info("Retrieved internal posts for linking",
"language", lang,
"count", len(allPosts),
)
return allPosts, nil
}
// GetCategories retrieves all categories
func (c *Client) GetCategories(ctx context.Context) ([]Category, error) {
endpoint := "/wp-json/wp/v2/categories"
params := url.Values{}
params.Set("per_page", "100")
var categories []Category
if err := c.get(ctx, endpoint, params, &categories); err != nil {
return nil, fmt.Errorf("failed to get categories: %w", err)
}
return categories, nil
}
// GetTags retrieves all tags
func (c *Client) GetTags(ctx context.Context) ([]Tag, error) {
endpoint := "/wp-json/wp/v2/tags"
params := url.Values{}
params.Set("per_page", "100")
var tags []Tag
if err := c.get(ctx, endpoint, params, &tags); err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
return tags, nil
}
// get performs a GET request to the WordPress API
func (c *Client) get(ctx context.Context, endpoint string, params url.Values, result interface{}) error {
fullURL := c.baseURL + endpoint
if len(params) > 0 {
fullURL += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
c.logger.Debug("WordPress API GET request",
"url", fullURL,
"endpoint", endpoint,
)
resp, err := c.doWithRetry(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("WordPress API error: status %d, body: %s", resp.StatusCode, string(body))
}
if result != nil {
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return nil
}
// post performs a POST/PUT request to the WordPress API
func (c *Client) post(ctx context.Context, endpoint string, payload interface{}, result interface{}) error {
fullURL := c.baseURL + endpoint
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
c.logger.Debug("WordPress API POST request",
"url", fullURL,
"endpoint", endpoint,
"payload_size", len(jsonData),
)
resp, err := c.doWithRetry(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("WordPress API error: status %d, body: %s", resp.StatusCode, string(body))
}
if result != nil {
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return nil
}
// doWithRetry executes an HTTP request with exponential backoff retry logic
func (c *Client) doWithRetry(req *http.Request) (*http.Response, error) {
maxRetries := 3
baseDelay := 1 * time.Second
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 1s, 2s, 4s
delay := baseDelay * time.Duration(1<<uint(attempt-1))
c.logger.Debug("Retrying request",
"attempt", attempt,
"delay", delay,
)
time.Sleep(delay)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
c.logger.Warn("Request failed",
"attempt", attempt+1,
"error", err,
)
continue
}
// Retry on 429 (rate limit) or 5xx errors
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
c.logger.Warn("Retryable error",
"attempt", attempt+1,
"status", resp.StatusCode,
)
continue
}
return resp, nil
}
return nil, fmt.Errorf("request failed after %d attempts: %w", maxRetries+1, lastErr)
}
+182
View File
@@ -0,0 +1,182 @@
package wordpress
import (
"encoding/json"
"strings"
"time"
)
// WPTime handles WordPress time formats that may omit timezone.
type WPTime struct {
time.Time
}
func (t *WPTime) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
if s == "" {
t.Time = time.Time{}
return nil
}
s = strings.TrimSpace(s)
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
}
var lastErr error
for _, layout := range layouts {
parsed, err := time.Parse(layout, s)
if err == nil {
t.Time = parsed
return nil
}
lastErr = err
}
return lastErr
}
// Post represents a WordPress post from the REST API
type Post struct {
ID int `json:"id"`
Date WPTime `json:"date"`
Modified WPTime `json:"modified"`
Slug string `json:"slug"`
Status string `json:"status"`
Type string `json:"type"`
Link string `json:"link"`
Title Rendered `json:"title"`
Content Rendered `json:"content"`
Excerpt Rendered `json:"excerpt"`
Author int `json:"author"`
// FeaturedMedia represents the featured image ID
FeaturedMedia int `json:"featured_media"`
// CommentStatus indicates if comments are allowed
CommentStatus string `json:"comment_status"`
// PingStatus indicates if pingbacks are allowed
PingStatus string `json:"ping_status"`
// Sticky indicates if the post is sticky
Sticky bool `json:"sticky"`
Format string `json:"format"`
Categories []int `json:"categories"`
Tags []int `json:"tags"`
// Meta holds custom meta fields
Meta map[string]interface{} `json:"meta"`
// Lang holds the Polylang language code (en, es, etc.)
Lang string `json:"lang,omitempty"`
}
// Rendered represents a WordPress rendered field (title, content, excerpt)
type Rendered struct {
Rendered string `json:"rendered"`
Raw string `json:"raw,omitempty"`
}
// Category represents a WordPress category
type Category struct {
ID int `json:"id"`
Count int `json:"count"`
Description string `json:"description"`
Link string `json:"link"`
Name string `json:"name"`
Slug string `json:"slug"`
Taxonomy string `json:"taxonomy"`
Parent int `json:"parent"`
}
// Tag represents a WordPress tag
type Tag struct {
ID int `json:"id"`
Count int `json:"count"`
Description string `json:"description"`
Link string `json:"link"`
Name string `json:"name"`
Slug string `json:"slug"`
Taxonomy string `json:"taxonomy"`
}
// PostUpdate represents the fields to update when creating a draft revision
type PostUpdate struct {
// Status should be "draft" for revisions pending approval
Status string `json:"status"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
// Meta holds SEO meta fields and optimization metadata
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.)
Lang string
// Status filters by post status (publish, draft, pending, etc.)
Status string
// OrderBy specifies the field to order by (modified, date, title, etc.)
OrderBy string
// Order specifies the order direction (asc, desc)
Order string
// PerPage specifies the number of posts per page (default: 10, max: 100)
PerPage int
// Page specifies the page number
Page int
// After filters posts modified after this date
After *time.Time
// Before filters posts modified before this date
Before *time.Time
// Search filters posts by search term
Search string
// Categories filters by category IDs
Categories []int
// Tags filters by tag IDs
Tags []int
}
// InternalPostReference represents a simplified post reference for internal linking
type InternalPostReference struct {
ID int `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Link string `json:"link"`
Categories []int `json:"categories"`
Tags []int `json:"tags"`
Excerpt string `json:"excerpt"`
}
// SelectionCriteria represents criteria for selecting posts to optimize
type SelectionCriteria struct {
// Language is the Polylang language code
Language string
// MinContentLength is the minimum word count (in words)
MinContentLength int
// MinAgeDays only considers posts older than this many days (to avoid very recent posts)
MinAgeDays int
// MaxAgeDays only considers posts not older than this many days
MaxAgeDays int
// CooldownDays don't re-optimize posts for this many days
CooldownDays int
// ExcludeCategories optionally excludes specific category IDs
ExcludeCategories []int
}
// MetaFields represents common WordPress SEO meta field names
// These work with both Yoast SEO and RankMath
const (
// SEO meta fields (RankMath)
MetaRankMathTitle = "rank_math_title"
MetaRankMathDescription = "rank_math_description"
MetaRankMathFocusKW = "rank_math_focus_keyword"
// SEO meta fields (Yoast)
MetaYoastTitle = "_yoast_wpseo_title"
MetaYoastDescription = "_yoast_wpseo_metadesc"
MetaYoastFocusKW = "_yoast_wpseo_focuskw"
// Custom meta fields for optimization tracking
MetaSEOOptimizedAt = "_seo_optimized_at"
MetaSEOOptimizationData = "_seo_optimization_data"
MetaSEOOptimizationSummary = "_seo_optimization_summary"
MetaDraftOfPostID = "_draft_of_post_id"
)
+283
View File
@@ -0,0 +1,283 @@
package wordpress
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"seo-optimizer/internal/logger"
)
// PostSelector handles intelligent post selection for optimization
type PostSelector struct {
client *Client
logger *logger.Logger
}
// NewPostSelector creates a new post selector
func NewPostSelector(client *Client, log *logger.Logger) *PostSelector {
if log == nil {
log = logger.NewDefaultLogger()
}
return &PostSelector{
client: client,
logger: log.WithComponent("selector"),
}
}
// SelectNextPost returns the oldest modified post that meets selection criteria
// Returns nil if no posts meet the criteria
func (s *PostSelector) SelectNextPost(ctx context.Context, criteria SelectionCriteria) (*Post, error) {
s.logger.Info("Selecting next post for optimization",
"language", criteria.Language,
"min_words", criteria.MinContentLength,
"min_age_days", criteria.MinAgeDays,
"max_age_days", criteria.MaxAgeDays,
"cooldown_days", criteria.CooldownDays,
)
// Calculate cutoff dates
maxAgeDate := time.Now().AddDate(0, 0, -criteria.MaxAgeDays)
minAgeDate := time.Now().AddDate(0, 0, -criteria.MinAgeDays)
// Fetch published posts ordered by modified date (oldest first)
// Only get posts that are older than minAgeDays but not older than maxAgeDays
params := ListParams{
Lang: criteria.Language,
Status: "publish",
OrderBy: "modified",
Order: "asc",
PerPage: 50, // Check 50 posts at a time
Before: &minAgeDate,
After: &maxAgeDate,
}
page := 1
for {
params.Page = page
posts, err := s.client.ListPosts(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to list posts: %w", err)
}
if len(posts) == 0 {
s.logger.Info("No more posts to check")
break
}
s.logger.Debug("Checking posts batch",
"page", page,
"count", len(posts),
)
// Check each post against criteria
for _, post := range posts {
if s.meetsAllCriteria(ctx, post, criteria) {
s.logger.Info("Selected post for optimization",
"post_id", post.ID,
"title", post.Title.Rendered,
"modified", post.Modified,
"word_count", s.countWords(post.Content.Rendered),
)
return post, nil
}
}
// If we got less than per_page results, we're done
if len(posts) < params.PerPage {
break
}
page++
// Safety limit
if page > 20 {
s.logger.Warn("Reached page limit while selecting post", "pages", page)
break
}
}
s.logger.Info("No eligible posts found for optimization", "language", criteria.Language)
return nil, nil
}
// meetsAllCriteria checks if a post meets all selection criteria
func (s *PostSelector) meetsAllCriteria(ctx context.Context, post *Post, criteria SelectionCriteria) bool {
// Check minimum content length
wordCount := s.countWords(post.Content.Rendered)
if wordCount < criteria.MinContentLength {
s.logger.Debug("Post rejected: too short",
"post_id", post.ID,
"word_count", wordCount,
"minimum", criteria.MinContentLength,
)
return false
}
// Check if post was recently optimized (cooldown period)
if s.wasRecentlyOptimized(post, criteria.CooldownDays) {
s.logger.Debug("Post rejected: recently optimized",
"post_id", post.ID,
)
return false
}
// Check excluded categories
if len(criteria.ExcludeCategories) > 0 {
for _, categoryID := range post.Categories {
for _, excludedID := range criteria.ExcludeCategories {
if categoryID == excludedID {
s.logger.Debug("Post rejected: excluded category",
"post_id", post.ID,
"category_id", categoryID,
)
return false
}
}
}
}
return true
}
// countWords counts the number of words in HTML content
// Strips HTML tags before counting
func (s *PostSelector) countWords(htmlContent string) int {
// Remove HTML tags
text := s.stripHTML(htmlContent)
// Remove extra whitespace
text = strings.TrimSpace(text)
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Split by whitespace and count
if text == "" {
return 0
}
words := strings.Fields(text)
return len(words)
}
// stripHTML removes HTML tags from content
func (s *PostSelector) stripHTML(html string) string {
// Remove script and style content
html = regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`).ReplaceAllString(html, "")
html = regexp.MustCompile(`(?i)<style[^>]*>.*?</style>`).ReplaceAllString(html, "")
// Remove HTML comments
html = regexp.MustCompile(`<!--.*?-->`).ReplaceAllString(html, "")
// Remove all HTML tags
html = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(html, " ")
// Decode common HTML entities
html = strings.ReplaceAll(html, "&nbsp;", " ")
html = strings.ReplaceAll(html, "&amp;", "&")
html = strings.ReplaceAll(html, "&lt;", "<")
html = strings.ReplaceAll(html, "&gt;", ">")
html = strings.ReplaceAll(html, "&quot;", "\"")
html = strings.ReplaceAll(html, "&#39;", "'")
return html
}
// wasRecentlyOptimized checks if a post was optimized within the cooldown period
func (s *PostSelector) wasRecentlyOptimized(post *Post, cooldownDays int) bool {
if post.Meta == nil {
return false
}
optimizedAtValue, exists := post.Meta[MetaSEOOptimizedAt]
if !exists || optimizedAtValue == nil {
return false
}
// The value might be a float64 (JSON number) or int64
var optimizedAtUnix int64
switch v := optimizedAtValue.(type) {
case float64:
optimizedAtUnix = int64(v)
case int64:
optimizedAtUnix = v
case int:
optimizedAtUnix = int64(v)
case string:
// Try to parse as int if it's a string
fmt.Sscanf(v, "%d", &optimizedAtUnix)
default:
s.logger.Debug("Unknown type for optimization timestamp",
"post_id", post.ID,
"type", fmt.Sprintf("%T", v),
)
return false
}
if optimizedAtUnix == 0 {
return false
}
optimizedAt := time.Unix(optimizedAtUnix, 0)
cooldownUntil := optimizedAt.AddDate(0, 0, cooldownDays)
if time.Now().Before(cooldownUntil) {
s.logger.Debug("Post in cooldown period",
"post_id", post.ID,
"optimized_at", optimizedAt,
"cooldown_until", cooldownUntil,
)
return true
}
return false
}
// MarkAsOptimized updates the post meta to record the optimization timestamp
func (s *PostSelector) MarkAsOptimized(ctx context.Context, postID int) error {
now := time.Now().Unix()
if err := s.client.UpdatePostMeta(ctx, postID, MetaSEOOptimizedAt, now); err != nil {
return fmt.Errorf("failed to mark post as optimized: %w", err)
}
s.logger.Info("Marked post as optimized",
"post_id", postID,
"timestamp", now,
)
return nil
}
// GetOptimizationTimestamp returns when a post was last optimized
func (s *PostSelector) GetOptimizationTimestamp(ctx context.Context, postID int) (*time.Time, error) {
value, err := s.client.GetPostMeta(ctx, postID, MetaSEOOptimizedAt)
if err != nil {
return nil, err
}
if value == nil {
return nil, nil
}
var optimizedAtUnix int64
switch v := value.(type) {
case float64:
optimizedAtUnix = int64(v)
case int64:
optimizedAtUnix = v
case int:
optimizedAtUnix = int64(v)
default:
return nil, fmt.Errorf("unexpected type for optimization timestamp: %T", v)
}
if optimizedAtUnix == 0 {
return nil, nil
}
timestamp := time.Unix(optimizedAtUnix, 0)
return &timestamp, nil
}
+81
View File
@@ -0,0 +1,81 @@
package models
// OptimizationResponse represents the structured JSON response from Claude
// This is the complete output from the SEO optimization prompt
type OptimizationResponse struct {
SEOMeta SEOMeta `json:"seo_meta"`
ContentOptimization ContentOptimization `json:"content_optimization"`
FAQBlock FAQBlock `json:"faq_block"`
Validation Validation `json:"validation"`
Summary string `json:"summary"`
}
// SEOMeta holds the optimized meta title and description
type SEOMeta struct {
Title string `json:"title"` // 50-60 characters
Description string `json:"description"` // 150-160 characters
}
// ContentOptimization holds all content-level optimizations
type ContentOptimization struct {
Changes []ContentChange `json:"changes"`
InternalLinks []InternalLink `json:"internal_links"`
}
// ContentChange represents a specific content modification
type ContentChange struct {
Type string `json:"type"` // keyword_injection, heading_improvement, readability, technical_update
Location string `json:"location"` // heading, paragraph identifier
Original string `json:"original"` // excerpt or full text being replaced
Updated string `json:"updated"` // new text
Reason string `json:"reason"` // detailed explanation (in Spanish)
}
// InternalLink represents a suggested internal link to add
type InternalLink struct {
AnchorText string `json:"anchor_text"`
TargetPostID int `json:"target_post_id"`
TargetSlug string `json:"target_slug"`
InsertionContext string `json:"insertion_context"` // where to insert
Reason string `json:"reason"` // why this link is relevant (in Spanish)
}
// FAQBlock holds FAQ schema data for RankMath
type FAQBlock struct {
ShouldCreate bool `json:"should_create"`
Reason string `json:"reason"`
Questions []FAQQuestion `json:"questions"`
}
// FAQQuestion represents a single FAQ item
type FAQQuestion struct {
ID string `json:"id"` // faq-question-{timestamp}
Title string `json:"title"` // the question
Content string `json:"content"` // the answer (40-80 words, can include HTML)
Visible bool `json:"visible"` // always true
}
// Validation holds fact-checking and link validation results
type Validation struct {
FactChecks []FactCheck `json:"fact_checks"`
BrokenLinks []BrokenLink `json:"broken_links"`
}
// 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
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)
}
// BrokenLink represents a broken or problematic link
type BrokenLink struct {
URL string `json:"url"`
Status string `json:"status"` // 404, timeout, redirect, moved
Replacement string `json:"replacement"` // new URL if found
Action string `json:"action"` // replace, remove
Reason string `json:"reason"` // explanation (in Spanish)
}
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -e
echo "This script has been replaced by the Go CLI."
echo ""
echo "Build:"
echo " go build -o wp-sk-cli ./cmd/cli"
echo ""
echo "Usage examples:"
echo " ./wp-sk-cli pending"
echo " ./wp-sk-cli changes 123"
echo " WPSK_API_URL=http://server:8080 ./wp-sk-cli pending"
echo " ./wp-sk-cli approve 456 --api http://server:8080"
exit 1