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