diff --git a/cmd/optimizer/main.go b/cmd/optimizer/main.go
index 0299d2f..7d1e539 100644
--- a/cmd/optimizer/main.go
+++ b/cmd/optimizer/main.go
@@ -20,6 +20,7 @@ import (
"seo-optimizer/internal/newsletter"
"seo-optimizer/internal/scheduler"
"seo-optimizer/internal/seo"
+ "seo-optimizer/internal/seoaudit"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/translation"
"seo-optimizer/internal/wordpress"
@@ -167,6 +168,14 @@ func main() {
}
log.Info("Image audit storage initialized", "base_path", cfg.ImageAudit.BasePath)
+ // Initialize SEO audit storage
+ seoAuditStore := storage.NewSEOAuditStorage(cfg.SEOAudit.BasePath, log)
+ if err := seoAuditStore.Initialize(); err != nil {
+ log.Error("Failed to initialize SEO audit storage", "error", err)
+ os.Exit(1)
+ }
+ log.Info("SEO audit storage initialized", "base_path", cfg.SEOAudit.BasePath)
+
// Initialize idea service
ideaService := ideas.NewService(wpClient, llmClient, nil, ideaStore, cfg.Ideas, outreachStore, cfg.Outreach, cfg.Runtime.DryRun, log)
@@ -186,6 +195,12 @@ func main() {
os.Exit(1)
}
+ seoAuditService, err := seoaudit.NewService(wpClient, seoAuditStore, cfg.SEOAudit, log)
+ if err != nil {
+ log.Error("Failed to initialize SEO audit service", "error", err)
+ os.Exit(1)
+ }
+
translationService := translation.NewService(wpClient, translationClient, log)
// Initialize optimizer
@@ -199,7 +214,7 @@ func main() {
// Start API server if enabled
var apiServer *api.Server
if cfg.API.Enabled {
- apiServer = api.NewServer(optimizer, wpClient, store, auditStore, ideaService, newsService, linkService, imageAuditService, translationService, version, cfg.API.Port, log)
+ apiServer = api.NewServer(optimizer, wpClient, store, auditStore, ideaService, newsService, linkService, imageAuditService, seoAuditService, translationService, version, cfg.API.Port, log)
// Start server in goroutine
go func() {
diff --git a/configs/config.example.yaml b/configs/config.example.yaml
index 93b2322..21e75a9 100644
--- a/configs/config.example.yaml
+++ b/configs/config.example.yaml
@@ -243,6 +243,19 @@ image_audit:
# Max bytes to download per image when fixing
max_download_bytes: 20971520
+seo_audit:
+ # Enable/disable SEO audit scoring
+ enabled: true
+
+ # Base URL for resolving internal vs external links
+ base_url: ""
+
+ # Directory to store SEO audit reports
+ base_path: "./data/seo-audits"
+
+ # Max posts to scan (0 = all)
+ max_posts: 0
+
storage:
# Directory to store optimization records
# This stores pending drafts and optimization history
diff --git a/deployments/kubernetes/deployment.yaml b/deployments/kubernetes/deployment.yaml
index 01d714c..80b7ee2 100644
--- a/deployments/kubernetes/deployment.yaml
+++ b/deployments/kubernetes/deployment.yaml
@@ -224,6 +224,11 @@ data:
min_score: 10
min_comments: 2
base_path: "/app/data/newsletters"
+ seo_audit:
+ enabled: true
+ base_url: ""
+ base_path: "/app/data/seo-audits"
+ max_posts: 0
api:
enabled: true
port: 8080
diff --git a/internal/agent/claude.go b/internal/agent/claude.go
index 6c5e506..ae79775 100644
--- a/internal/agent/claude.go
+++ b/internal/agent/claude.go
@@ -93,6 +93,7 @@ func (c *ClaudeClient) OptimizePost(
internalPosts []*wordpress.InternalPostReference,
categories map[int]string,
tags map[int]string,
+ options *OptimizeOptions,
) (*models.OptimizationResponse, error) {
c.logger.Info("Optimizing post with Claude",
"post_id", post.ID,
@@ -102,7 +103,11 @@ func (c *ClaudeClient) OptimizePost(
)
// Build prompt data
- promptData := BuildPromptData(post, internalPosts, categories, tags)
+ feedback := ""
+ if options != nil {
+ feedback = options.Feedback
+ }
+ promptData := BuildPromptData(post, internalPosts, categories, tags, feedback)
// Render prompt
prompt, err := RenderPrompt(promptData)
diff --git a/internal/agent/deepseek.go b/internal/agent/deepseek.go
index 2019d80..5cc9bbf 100644
--- a/internal/agent/deepseek.go
+++ b/internal/agent/deepseek.go
@@ -82,6 +82,7 @@ func (c *DeepSeekClient) OptimizePost(
internalPosts []*wordpress.InternalPostReference,
categories map[int]string,
tags map[int]string,
+ options *OptimizeOptions,
) (*models.OptimizationResponse, error) {
c.logger.Info("Optimizing post with DeepSeek",
"post_id", post.ID,
@@ -90,7 +91,11 @@ func (c *DeepSeekClient) OptimizePost(
"language", post.Lang,
)
- promptData := BuildPromptData(post, internalPosts, categories, tags)
+ feedback := ""
+ if options != nil {
+ feedback = options.Feedback
+ }
+ promptData := BuildPromptData(post, internalPosts, categories, tags, feedback)
prompt, err := RenderPrompt(promptData)
if err != nil {
return nil, fmt.Errorf("failed to render prompt: %w", err)
diff --git a/internal/agent/llm.go b/internal/agent/llm.go
index 22fe058..a550e86 100644
--- a/internal/agent/llm.go
+++ b/internal/agent/llm.go
@@ -15,6 +15,7 @@ type LLMClient interface {
internalPosts []*wordpress.InternalPostReference,
categories map[int]string,
tags map[int]string,
+ options *OptimizeOptions,
) (*models.OptimizationResponse, error)
GeneratePostIdeas(ctx context.Context, prompt string) ([]*models.PostIdea, error)
@@ -22,3 +23,8 @@ type LLMClient interface {
GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error)
GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error)
}
+
+// OptimizeOptions provides optional inputs to guide optimization.
+type OptimizeOptions struct {
+ Feedback string
+}
diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go
index 14ebf36..0ca2762 100644
--- a/internal/agent/prompts.go
+++ b/internal/agent/prompts.go
@@ -24,6 +24,7 @@ type PromptData struct {
LastModified string
Content string
InternalPosts []InternalPostRef
+ Feedback string
}
// InternalPostRef is a simplified reference to an internal post for linking
@@ -67,6 +68,11 @@ Tags: {{.Tags}}
Word Count: {{.WordCount}}
Last Modified: {{.LastModified}}
+{{if .Feedback}}
+**Reviewer Feedback (apply these instructions carefully)**:
+{{.Feedback}}
+{{end}}
+
**Content**:
{{.Content}}
@@ -160,11 +166,13 @@ Respond with ONLY valid JSON (no markdown code blocks, no explanations outside J
- Use descriptive anchor text (avoid "click here", "this post")
- Vary anchor text (don't repeat same phrase)
- Insert links naturally in context (not forced)
+ - If a link does not fit in context, place it in a "Related posts" section at the end (with links)
- Prefer linking to comprehensive guides or deep technical posts
- Reasoning: explain topic relevance and SEO benefit
4. **FAQ Block (RankMath)** (4-6 questions):
- Create FAQ only if content benefits from structured Q&A
+ - If the post already has an FAQ section, update/replace the questions; DO NOT add a second FAQ section
- 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
@@ -198,6 +206,7 @@ Respond with ONLY valid JSON (no markdown code blocks, no explanations outside J
- 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.
+- If you add a table, use valid Gutenberg structure: wrap in and .
- 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
`
@@ -273,6 +282,7 @@ func BuildPromptData(
internalPosts []*wordpress.InternalPostReference,
categories map[int]string,
tags map[int]string,
+ feedback string,
) PromptData {
// Get SEO meta fields (try RankMath first, then Yoast)
seoTitle := getMetaString(post.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
@@ -336,6 +346,7 @@ func BuildPromptData(
LastModified: post.Modified.Format(time.RFC3339),
Content: post.Content.Rendered,
InternalPosts: internalRefs,
+ Feedback: strings.TrimSpace(feedback),
}
}
diff --git a/internal/api/server.go b/internal/api/server.go
index d605c62..f2257f3 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
+ "regexp"
"strconv"
"strings"
"sync"
@@ -13,6 +14,7 @@ import (
"github.com/google/uuid"
"github.com/gorilla/mux"
+ "golang.org/x/net/html"
"seo-optimizer/internal/brokenlinks"
"seo-optimizer/internal/ideas"
@@ -20,6 +22,7 @@ import (
"seo-optimizer/internal/logger"
"seo-optimizer/internal/newsletter"
"seo-optimizer/internal/seo"
+ "seo-optimizer/internal/seoaudit"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/translation"
"seo-optimizer/internal/wordpress"
@@ -35,6 +38,7 @@ type Server struct {
newsSvc *newsletter.Service
linkSvc *brokenlinks.Service
imageSvc *imageaudit.Service
+ seoAuditSvc *seoaudit.Service
translationSvc *translation.Service
version string
port int
@@ -50,6 +54,9 @@ type Server struct {
imageAuditMu sync.Mutex
imageAuditRunning bool
+
+ seoAuditMu sync.Mutex
+ seoAuditRunning bool
}
// Job represents an optimization job (running or completed)
@@ -66,7 +73,7 @@ type Job struct {
}
// NewServer creates a new API server
-func NewServer(optimizer *seo.Optimizer, wpClient *wordpress.Client, store *storage.OptimizationStorage, audit *storage.AuditStorage, ideaSvc *ideas.Service, newsSvc *newsletter.Service, linkSvc *brokenlinks.Service, imageSvc *imageaudit.Service, translationSvc *translation.Service, version string, 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, linkSvc *brokenlinks.Service, imageSvc *imageaudit.Service, seoAuditSvc *seoaudit.Service, translationSvc *translation.Service, version string, port int, log *logger.Logger) *Server {
if log == nil {
log = logger.NewDefaultLogger()
}
@@ -80,6 +87,7 @@ func NewServer(optimizer *seo.Optimizer, wpClient *wordpress.Client, store *stor
newsSvc: newsSvc,
linkSvc: linkSvc,
imageSvc: imageSvc,
+ seoAuditSvc: seoAuditSvc,
translationSvc: translationSvc,
version: version,
port: port,
@@ -116,6 +124,7 @@ func (s *Server) Start() error {
// API endpoints (no authentication - local access only)
router.HandleFunc("/api/v1/optimize", s.handleOptimize).Methods("POST")
+ router.HandleFunc("/api/v1/optimize/feedback", s.handleOptimizeFeedback).Methods("POST")
router.HandleFunc("/api/v1/apply-draft", s.handleApplyDraft).Methods("POST")
router.HandleFunc("/api/v1/reject-draft", s.handleRejectDraft).Methods("POST")
router.HandleFunc("/api/v1/pending", s.handleListPending).Methods("GET")
@@ -140,6 +149,9 @@ func (s *Server) Start() error {
router.HandleFunc("/api/v1/broken-links/scan", s.handleScanBrokenLinks).Methods("POST")
router.HandleFunc("/api/v1/image-audit", s.handleListImageAudit).Methods("GET")
router.HandleFunc("/api/v1/image-audit/scan", s.handleScanImageAudit).Methods("POST")
+ router.HandleFunc("/api/v1/seo-audit", s.handleListSEOAudit).Methods("GET")
+ router.HandleFunc("/api/v1/seo-audit/scan", s.handleScanSEOAudit).Methods("POST")
+ router.HandleFunc("/api/v1/search", s.handleSearchContent).Methods("POST")
router.HandleFunc("/api/v1/image-audit/fix", s.handleFixExternalImages).Methods("POST")
router.HandleFunc("/api/v1/image-audit/sync-featured", s.handleSyncFeaturedImage).Methods("POST")
router.HandleFunc("/api/v1/orphans", s.handleListOrphans).Methods("GET")
@@ -236,6 +248,85 @@ func (s *Server) handleOptimize(w http.ResponseWriter, r *http.Request) {
})
}
+// handleOptimizeFeedback handles POST /api/v1/optimize/feedback
+// Request: {"draft_post_id": 456, "feedback": "string"}
+// Response: 202 Accepted + {"job_id": "uuid", "status": "accepted"}
+func (s *Server) handleOptimizeFeedback(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ DraftPostID int `json:"draft_post_id"`
+ Feedback string `json:"feedback"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.DraftPostID <= 0 {
+ http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
+ return
+ }
+ if strings.TrimSpace(req.Feedback) == "" {
+ http.Error(w, "feedback is required", http.StatusBadRequest)
+ return
+ }
+
+ ctx := r.Context()
+ draftPost, err := s.wpClient.GetPostForEdit(ctx, req.DraftPostID, "")
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to fetch draft: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ originalPostID, err := s.getOriginalPostIDForDraft(ctx, req.DraftPostID)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to resolve original post: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ language := draftPost.Lang
+ if language == "" {
+ language = "en"
+ }
+
+ jobID := uuid.New().String()
+ job := &Job{
+ ID: jobID,
+ PostID: originalPostID,
+ Language: language,
+ Status: "running",
+ StartedAt: time.Now(),
+ RequestedBy: clientFromRequest(r),
+ }
+
+ s.jobsMu.Lock()
+ s.jobs[jobID] = job
+ s.jobsMu.Unlock()
+
+ s.logger.Info("Feedback optimization job started",
+ "job_id", jobID,
+ "draft_post_id", req.DraftPostID,
+ "post_id", originalPostID,
+ "language", language,
+ )
+ s.recordAudit(r, &storage.AuditRecord{
+ Action: "optimize_feedback_requested",
+ Summary: fmt.Sprintf("Feedback optimization requested for draft %d (post %d)", req.DraftPostID, originalPostID),
+ PostID: originalPostID,
+ JobID: jobID,
+ Language: language,
+ })
+
+ go s.runFeedbackOptimizationJob(job, req.Feedback)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "job_id": jobID,
+ "status": "accepted",
+ })
+}
+
// handleStatus handles GET /api/v1/status/:job_id
// Response: {"status": "running|completed|failed", "details": "..."}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -1053,6 +1144,320 @@ func (s *Server) handleScanImageAudit(w http.ResponseWriter, r *http.Request) {
})
}
+// handleListSEOAudit handles GET /api/v1/seo-audit
+func (s *Server) handleListSEOAudit(w http.ResponseWriter, r *http.Request) {
+ if s.seoAuditSvc == nil {
+ http.Error(w, "seo audit service not configured", http.StatusNotImplemented)
+ return
+ }
+ report, err := s.seoAuditSvc.LatestReport()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to fetch seo audit report: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "report": report,
+ })
+}
+
+// handleScanSEOAudit handles POST /api/v1/seo-audit/scan
+func (s *Server) handleScanSEOAudit(w http.ResponseWriter, r *http.Request) {
+ if s.seoAuditSvc == nil {
+ http.Error(w, "seo audit service not configured", http.StatusNotImplemented)
+ return
+ }
+
+ s.seoAuditMu.Lock()
+ if s.seoAuditRunning {
+ s.seoAuditMu.Unlock()
+ http.Error(w, "seo audit already running", http.StatusConflict)
+ return
+ }
+ s.seoAuditRunning = true
+ s.seoAuditMu.Unlock()
+
+ requestedBy := clientFromRequest(r)
+ go func() {
+ defer func() {
+ s.seoAuditMu.Lock()
+ s.seoAuditRunning = false
+ s.seoAuditMu.Unlock()
+ }()
+
+ report, err := s.seoAuditSvc.RunScan(context.Background())
+ if err != nil {
+ s.logger.Error("SEO audit failed", "error", err)
+ s.recordAudit(nil, &storage.AuditRecord{
+ Actor: requestedBy,
+ Action: "seo_audit_failed",
+ Summary: fmt.Sprintf("SEO audit failed: %v", err),
+ })
+ return
+ }
+ s.recordAudit(nil, &storage.AuditRecord{
+ Actor: requestedBy,
+ Action: "seo_audit_completed",
+ Summary: fmt.Sprintf("SEO audit completed (%d posts)", report.TotalPosts),
+ })
+ }()
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(map[string]string{
+ "status": "accepted",
+ })
+}
+
+// handleSearchContent handles POST /api/v1/search
+// Request: {"query":"text","case_sensitive":false,"exact_match":true,"regex":false,"block_snippet":false,"max_results":50,"max_posts":0}
+func (s *Server) handleSearchContent(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Query string `json:"query"`
+ CaseSensitive bool `json:"case_sensitive"`
+ ExactMatch bool `json:"exact_match"`
+ Regex bool `json:"regex"`
+ BlockSnippet bool `json:"block_snippet"`
+ MaxResults int `json:"max_results"`
+ MaxPosts int `json:"max_posts"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+ query := strings.TrimSpace(req.Query)
+ if query == "" {
+ http.Error(w, "query is required", http.StatusBadRequest)
+ return
+ }
+ if req.MaxResults <= 0 {
+ req.MaxResults = 50
+ }
+
+ var matcher *regexp.Regexp
+ if req.Regex {
+ pattern := query
+ if !req.CaseSensitive {
+ pattern = "(?i)" + pattern
+ }
+ var err error
+ matcher, err = regexp.Compile(pattern)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("invalid regex: %v", err), http.StatusBadRequest)
+ return
+ }
+ }
+
+ perPage := 100
+ page := 1
+ processed := 0
+ matches := make([]map[string]interface{}, 0)
+
+ for {
+ if req.MaxPosts > 0 && processed >= req.MaxPosts {
+ break
+ }
+ params := wordpress.ListParams{
+ Status: "publish",
+ OrderBy: "modified",
+ Order: "desc",
+ PerPage: perPage,
+ Page: page,
+ }
+ posts, err := s.wpClient.ListPosts(r.Context(), params)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to list posts: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if len(posts) == 0 {
+ break
+ }
+
+ for _, post := range posts {
+ if req.MaxPosts > 0 && processed >= req.MaxPosts {
+ break
+ }
+ processed += 1
+
+ content := post.Content.Rendered
+ if strings.TrimSpace(content) == "" {
+ continue
+ }
+
+ text := extractText(content)
+ haystack := text
+ needle := query
+ if !req.CaseSensitive && !req.Regex {
+ haystack = strings.ToLower(text)
+ needle = strings.ToLower(query)
+ }
+
+ var ok bool
+ var loc [2]int
+ if req.Regex {
+ idx := matcher.FindStringIndex(text)
+ if idx != nil {
+ ok = true
+ loc = [2]int{idx[0], idx[1]}
+ }
+ } else if req.ExactMatch {
+ if index := strings.Index(haystack, needle); index >= 0 {
+ ok = true
+ loc = [2]int{index, index + len(needle)}
+ }
+ } else {
+ ok = containsAllTokens(haystack, needle)
+ if ok {
+ if index := strings.Index(haystack, firstToken(needle)); index >= 0 {
+ loc = [2]int{index, index + len(firstToken(needle))}
+ }
+ }
+ }
+
+ if !ok {
+ continue
+ }
+
+ snippet := buildSnippet(text, loc[0], loc[1])
+ if req.BlockSnippet {
+ if block := extractBlockSnippet(content, query, req.CaseSensitive); block != "" {
+ snippet = block
+ }
+ }
+
+ matches = append(matches, map[string]interface{}{
+ "post_id": post.ID,
+ "title": post.Title.Rendered,
+ "language": post.Lang,
+ "link": post.Link,
+ "snippet": snippet,
+ })
+
+ if len(matches) >= req.MaxResults {
+ break
+ }
+ }
+
+ if len(posts) < perPage || len(matches) >= req.MaxResults {
+ break
+ }
+ page += 1
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "query": query,
+ "count": len(matches),
+ "matches": matches,
+ })
+}
+
+func extractText(content string) string {
+ root, err := html.Parse(strings.NewReader(content))
+ if err != nil {
+ return content
+ }
+ var b strings.Builder
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.TextNode {
+ text := strings.TrimSpace(n.Data)
+ if text != "" {
+ if b.Len() > 0 {
+ b.WriteByte(' ')
+ }
+ b.WriteString(text)
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(root)
+ return b.String()
+}
+
+func containsAllTokens(haystack, needle string) bool {
+ tokens := strings.Fields(needle)
+ if len(tokens) == 0 {
+ return false
+ }
+ for _, token := range tokens {
+ if !strings.Contains(haystack, token) {
+ return false
+ }
+ }
+ return true
+}
+
+func firstToken(value string) string {
+ tokens := strings.Fields(value)
+ if len(tokens) == 0 {
+ return ""
+ }
+ return tokens[0]
+}
+
+func buildSnippet(text string, start, end int) string {
+ if start < 0 || end <= start {
+ if len(text) > 180 {
+ return text[:180] + "..."
+ }
+ return text
+ }
+ window := 120
+ if len(text) <= window {
+ return text
+ }
+ snippetStart := start - window
+ if snippetStart < 0 {
+ snippetStart = 0
+ }
+ snippetEnd := end + window
+ if snippetEnd > len(text) {
+ snippetEnd = len(text)
+ }
+ prefix := ""
+ suffix := ""
+ if snippetStart > 0 {
+ prefix = "..."
+ }
+ if snippetEnd < len(text) {
+ suffix = "..."
+ }
+ return prefix + text[snippetStart:snippetEnd] + suffix
+}
+
+func extractBlockSnippet(content, query string, caseSensitive bool) string {
+ idx := contentIndex(content, query, caseSensitive)
+ if idx < 0 {
+ return ""
+ }
+ openIdx := strings.LastIndex(content[:idx], "")
+ if endIdx == -1 {
+ return ""
+ }
+ block := content[openIdx : closeIdx+endIdx+3]
+ return buildSnippet(extractText(block), 0, 0)
+}
+
+func contentIndex(content, query string, caseSensitive bool) int {
+ if caseSensitive {
+ return strings.Index(content, query)
+ }
+ return strings.Index(strings.ToLower(content), strings.ToLower(query))
+}
+
// handleFixExternalImages handles POST /api/v1/image-audit/fix
// Request: {"post_id": 123}
func (s *Server) handleFixExternalImages(w http.ResponseWriter, r *http.Request) {
@@ -1386,6 +1791,80 @@ func (s *Server) runOptimizationJob(job *Job) {
}
}
+func (s *Server) runFeedbackOptimizationJob(job *Job, feedback string) {
+ ctx := context.Background()
+
+ s.logger.Info("Running feedback optimization job",
+ "job_id", job.ID,
+ "post_id", job.PostID,
+ "language", job.Language,
+ )
+
+ err := s.optimizer.OptimizeSpecificPostWithFeedback(ctx, job.PostID, job.Language, feedback)
+
+ var auditRecord *storage.AuditRecord
+ s.jobsMu.Lock()
+
+ now := time.Now()
+ job.EndedAt = &now
+
+ if err != nil {
+ var skipErr *seo.SkipError
+ if errors.As(err, &skipErr) {
+ job.Status = "completed"
+ job.Summary = fmt.Sprintf("Skipped: %s", skipErr.Reason)
+ s.logger.Info("Feedback optimization job skipped",
+ "job_id", job.ID,
+ "reason", skipErr.Reason,
+ )
+ auditRecord = &storage.AuditRecord{
+ Actor: job.RequestedBy,
+ Action: "optimize_feedback_skipped",
+ Summary: job.Summary,
+ PostID: job.PostID,
+ JobID: job.ID,
+ Language: job.Language,
+ }
+ s.jobsMu.Unlock()
+ s.recordAudit(nil, auditRecord)
+ return
+ }
+
+ job.Status = "failed"
+ job.Error = err.Error()
+ s.logger.Error("Feedback optimization job failed",
+ "job_id", job.ID,
+ "error", err,
+ )
+ auditRecord = &storage.AuditRecord{
+ Actor: job.RequestedBy,
+ Action: "optimize_feedback_failed",
+ Summary: fmt.Sprintf("Feedback 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 = "Feedback optimization completed successfully"
+ s.logger.Info("Feedback optimization job completed",
+ "job_id", job.ID,
+ "post_id", job.PostID,
+ )
+ auditRecord = &storage.AuditRecord{
+ Actor: job.RequestedBy,
+ Action: "optimize_feedback_completed",
+ Summary: fmt.Sprintf("Feedback optimization completed for post %d", job.PostID),
+ PostID: job.PostID,
+ JobID: job.ID,
+ Language: job.Language,
+ }
+ }
+
+ s.jobsMu.Unlock()
+ s.recordAudit(nil, auditRecord)
+}
+
// CleanupOldJobs removes completed jobs older than the specified duration
// This prevents memory growth in long-running deployments
func (s *Server) CleanupOldJobs(maxAge time.Duration) {
diff --git a/internal/api/ui/app.js b/internal/api/ui/app.js
index deded3e..b413640 100644
--- a/internal/api/ui/app.js
+++ b/internal/api/ui/app.js
@@ -24,6 +24,7 @@ const state = {
audit: [],
brokenLinks: null,
imageAudit: null,
+ seoAudit: null,
orphans: [],
};
@@ -158,6 +159,9 @@ const renderModal = () => {
content.className = "modal-text";
content.textContent = stripHTML(draft.content || "");
body.appendChild(content);
+ if (draft.id) {
+ body.appendChild(renderFeedbackPanel(draft.id));
+ }
return;
}
@@ -205,6 +209,51 @@ const renderModal = () => {
grid.appendChild(originalCol);
grid.appendChild(draftCol);
body.appendChild(grid);
+ if (draft.id) {
+ body.appendChild(renderFeedbackPanel(draft.id));
+ }
+};
+
+const renderFeedbackPanel = (draftId) => {
+ const panel = document.createElement("div");
+ panel.className = "feedback-panel";
+
+ const title = document.createElement("h4");
+ title.textContent = "Regenerate with feedback";
+ panel.appendChild(title);
+
+ const textarea = document.createElement("textarea");
+ textarea.placeholder = "Add specific feedback to re-run optimization (e.g., keep intro tone, fix FAQ Q3, adjust internal links).";
+ panel.appendChild(textarea);
+
+ const actions = document.createElement("div");
+ actions.className = "feedback-actions";
+ const button = document.createElement("button");
+ button.textContent = "Regenerate draft";
+ actions.appendChild(button);
+ panel.appendChild(actions);
+
+ button.addEventListener("click", async () => {
+ const feedback = textarea.value.trim();
+ if (!feedback) {
+ textarea.focus();
+ return;
+ }
+ setOutput("Submitting feedback and regenerating draft...");
+ try {
+ await apiFetch("/api/v1/optimize/feedback", {
+ method: "POST",
+ body: JSON.stringify({ draft_post_id: Number(draftId), feedback }),
+ });
+ textarea.value = "";
+ await loadPending();
+ await loadAudit();
+ } catch (err) {
+ appendOutput(`Error: ${err.message}`);
+ }
+ });
+
+ return panel;
};
const showModalText = (title, body) => {
@@ -431,6 +480,42 @@ const renderImageAudit = () => {
}
};
+const renderSEOAudit = () => {
+ const summary = byId("seo-audit-summary");
+ const list = byId("seo-audit-list");
+ list.innerHTML = "";
+
+ const report = state.seoAudit?.report;
+ if (!report) {
+ summary.textContent = "No SEO audit report yet.";
+ return;
+ }
+
+ summary.textContent = `Last scan: ${new Date(report.created_at).toLocaleString()} • Posts checked: ${report.total_posts}`;
+
+ if (!report.posts || report.posts.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "meta muted";
+ empty.textContent = "No SEO audit data.";
+ list.appendChild(empty);
+ return;
+ }
+
+ report.posts.forEach((post) => {
+ const el = document.createElement("div");
+ el.className = "item";
+ const issues = (post.issues || []).map((issue) => `${issue.type}:${issue.details}`).join(", ");
+ el.innerHTML = `
+
${post.post_title || "Untitled"}
+ Post ID: ${post.post_id} • Score: ${post.score}
+ Title: ${post.title_score} • Description: ${post.description_score} • Internal Links: ${post.internal_link_count} (${post.internal_link_score}) • External Links: ${post.external_link_count} (${post.external_link_score})
+ ${post.post_url ? `` : ""}
+ ${issues ? `Issues: ${issues}
` : ""}
+ `;
+ list.appendChild(el);
+ });
+};
+
const renderOrphans = () => {
const list = byId("orphans-list");
const summary = byId("orphans-summary");
@@ -546,6 +631,12 @@ const loadImageAudit = async () => {
renderImageAudit();
};
+const loadSEOAudit = async () => {
+ const res = await apiFetch("/api/v1/seo-audit");
+ state.seoAudit = res || {};
+ renderSEOAudit();
+};
+
const loadOrphans = async () => {
const res = await apiFetch("/api/v1/orphans?source=en&target=es");
state.orphans = res.orphans || [];
@@ -567,6 +658,7 @@ const refreshAll = async () => {
loadNewsletters(),
loadBrokenLinks(),
loadImageAudit(),
+ loadSEOAudit(),
loadOrphans(),
loadAudit(),
]);
@@ -574,7 +666,7 @@ const refreshAll = async () => {
const commandHandlers = {
help: async () =>
- "Commands: health, version, pending [summary|text ], approve , reject , ideas [status|generate|draft |publish |delete ], outreach [status|generate|delete ], newsletter [status|generate|delete ], broken-links [scan], image-audit [scan|fix |sync [lang]], orphans, translate [lang], optimize [lang], changes , status , audit [limit]",
+ "Commands: health, version, pending [summary|text ], approve , reject , ideas [status|generate|draft |publish |delete ], outreach [status|generate|delete ], newsletter [status|generate|delete ], broken-links [scan], image-audit [scan|fix |sync [lang]], seo-audit [scan], search [--case] [--exact] [--regex] [--block] [--max=N] [--posts=N], orphans, translate [lang], optimize [lang], changes , status , 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) => {
@@ -670,6 +762,32 @@ const commandHandlers = {
}
return JSON.stringify(await apiFetch("/api/v1/image-audit"), null, 2);
},
+ "seo-audit": async (args) => {
+ if (args[0] === "scan") {
+ await apiFetch("/api/v1/seo-audit/scan", { method: "POST" });
+ return "SEO audit started.";
+ }
+ return JSON.stringify(await apiFetch("/api/v1/seo-audit"), null, 2);
+ },
+ search: async (args) => {
+ if (!args.length) return "search [--case] [--exact] [--regex] [--block] [--max=N] [--posts=N]";
+ const flags = new Set(args.filter((t) => t.startsWith("--")));
+ const rawQuery = args.filter((t) => !t.startsWith("--")).join(" ").trim();
+ const maxFlag = args.find((t) => t.startsWith("--max="));
+ const postsFlag = args.find((t) => t.startsWith("--posts="));
+ const maxResults = maxFlag ? Number(maxFlag.split("=")[1]) : 50;
+ const maxPosts = postsFlag ? Number(postsFlag.split("=")[1]) : 0;
+ const payload = {
+ query: rawQuery,
+ case_sensitive: flags.has("--case"),
+ exact_match: flags.has("--exact"),
+ regex: flags.has("--regex"),
+ block_snippet: flags.has("--block"),
+ max_results: Number.isFinite(maxResults) ? maxResults : 50,
+ max_posts: Number.isFinite(maxPosts) ? maxPosts : 0,
+ };
+ return JSON.stringify(await apiFetch("/api/v1/search", { method: "POST", body: JSON.stringify(payload) }), null, 2);
+ },
orphans: async () => JSON.stringify(await apiFetch("/api/v1/orphans?source=en&target=es"), null, 2),
translate: async (args) => {
if (!args[0]) return "translate [lang]";
@@ -754,6 +872,12 @@ const wireActions = () => {
await apiFetch("/api/v1/image-audit/scan", { method: "POST" });
await loadImageAudit();
});
+ byId("refresh-seo-audit").addEventListener("click", loadSEOAudit);
+ byId("scan-seo-audit").addEventListener("click", async () => {
+ setOutput("Starting SEO audit...");
+ await apiFetch("/api/v1/seo-audit/scan", { method: "POST" });
+ await loadSEOAudit();
+ });
byId("refresh-orphans").addEventListener("click", loadOrphans);
byId("refresh-audit").addEventListener("click", loadAudit);
byId("command-run").addEventListener("click", runCommand);
diff --git a/internal/api/ui/index.html b/internal/api/ui/index.html
index c80ebe0..c7dc265 100644
--- a/internal/api/ui/index.html
+++ b/internal/api/ui/index.html
@@ -143,6 +143,18 @@
+
+
diff --git a/internal/api/ui/styles.css b/internal/api/ui/styles.css
index 39889d7..5e51e69 100644
--- a/internal/api/ui/styles.css
+++ b/internal/api/ui/styles.css
@@ -209,6 +209,36 @@ button:active {
white-space: pre-wrap;
}
+.feedback-panel {
+ margin-top: 16px;
+ padding: 14px;
+ border-radius: 14px;
+ border: 1px solid var(--stroke);
+ background: #fff;
+ display: grid;
+ gap: 10px;
+}
+
+.feedback-panel h4 {
+ margin: 0;
+ font-size: 16px;
+}
+
+.feedback-panel textarea {
+ min-height: 90px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ border: 1px solid var(--stroke);
+ font-family: inherit;
+ resize: vertical;
+}
+
+.feedback-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+}
+
.section {
display: grid;
gap: 16px;
diff --git a/internal/config/config.go b/internal/config/config.go
index 35dfb30..ec8c17c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -22,6 +22,7 @@ type Config struct {
Outreach OutreachConfig `mapstructure:"outreach"`
Newsletter NewsletterConfig `mapstructure:"newsletter"`
ImageAudit ImageAuditConfig `mapstructure:"image_audit"`
+ SEOAudit SEOAuditConfig `mapstructure:"seo_audit"`
LinkChecker LinkCheckerConfig `mapstructure:"link_checker"`
Storage StorageConfig `mapstructure:"storage"`
Audit AuditConfig `mapstructure:"audit"`
@@ -141,6 +142,14 @@ type ImageAuditConfig struct {
MaxDownloadBytes int64 `mapstructure:"max_download_bytes"`
}
+// SEOAuditConfig holds SEO audit configuration.
+type SEOAuditConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ BaseURL string `mapstructure:"base_url"`
+ BasePath string `mapstructure:"base_path"`
+ MaxPosts int `mapstructure:"max_posts"`
+}
+
// LinkCheckerConfig holds broken link scanning configuration.
type LinkCheckerConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -246,6 +255,10 @@ func LoadConfig(configPath string) (*Config, error) {
viper.BindEnv("image_audit.allowed_host_suffixes", "IMAGE_AUDIT_ALLOWED_HOST_SUFFIXES")
viper.BindEnv("image_audit.download_timeout", "IMAGE_AUDIT_DOWNLOAD_TIMEOUT")
viper.BindEnv("image_audit.max_download_bytes", "IMAGE_AUDIT_MAX_DOWNLOAD_BYTES")
+ viper.BindEnv("seo_audit.enabled", "SEO_AUDIT_ENABLED")
+ viper.BindEnv("seo_audit.base_url", "SEO_AUDIT_BASE_URL")
+ viper.BindEnv("seo_audit.base_path", "SEO_AUDIT_BASE_PATH")
+ viper.BindEnv("seo_audit.max_posts", "SEO_AUDIT_MAX_POSTS")
viper.BindEnv("link_checker.enabled", "LINK_CHECKER_ENABLED")
viper.BindEnv("link_checker.interval_hours", "LINK_CHECKER_INTERVAL_HOURS")
viper.BindEnv("link_checker.request_timeout", "LINK_CHECKER_TIMEOUT")
@@ -312,6 +325,8 @@ func LoadConfig(configPath string) (*Config, error) {
config.Newsletter.BasePath = os.ExpandEnv(config.Newsletter.BasePath)
config.ImageAudit.BasePath = os.ExpandEnv(config.ImageAudit.BasePath)
config.ImageAudit.BaseURL = os.ExpandEnv(config.ImageAudit.BaseURL)
+ config.SEOAudit.BasePath = os.ExpandEnv(config.SEOAudit.BasePath)
+ config.SEOAudit.BaseURL = os.ExpandEnv(config.SEOAudit.BaseURL)
config.LinkChecker.BasePath = os.ExpandEnv(config.LinkChecker.BasePath)
config.LinkChecker.BaseURL = os.ExpandEnv(config.LinkChecker.BaseURL)
config.Audit.BasePath = os.ExpandEnv(config.Audit.BasePath)
@@ -320,6 +335,9 @@ func LoadConfig(configPath string) (*Config, error) {
if strings.TrimSpace(config.ImageAudit.BaseURL) == "" {
config.ImageAudit.BaseURL = config.WordPress.BaseURL
}
+ if strings.TrimSpace(config.SEOAudit.BaseURL) == "" {
+ config.SEOAudit.BaseURL = config.WordPress.BaseURL
+ }
if strings.TrimSpace(config.LinkChecker.BaseURL) == "" {
config.LinkChecker.BaseURL = config.WordPress.BaseURL
}
@@ -406,6 +424,10 @@ func setDefaults() {
viper.SetDefault("image_audit.allowed_host_suffixes", []string{"optimole.com"})
viper.SetDefault("image_audit.download_timeout", 20*time.Second)
viper.SetDefault("image_audit.max_download_bytes", int64(20*1024*1024))
+ viper.SetDefault("seo_audit.enabled", true)
+ viper.SetDefault("seo_audit.base_url", "")
+ viper.SetDefault("seo_audit.base_path", "./data/seo-audits")
+ viper.SetDefault("seo_audit.max_posts", 0)
// Link checker defaults
viper.SetDefault("link_checker.enabled", true)
@@ -614,6 +636,14 @@ func validateConfig(cfg *Config) error {
return fmt.Errorf("image_audit.max_download_bytes must be positive")
}
}
+ if cfg.SEOAudit.Enabled {
+ if strings.TrimSpace(cfg.SEOAudit.BasePath) == "" {
+ return fmt.Errorf("seo_audit.base_path is required")
+ }
+ if cfg.SEOAudit.MaxPosts < 0 {
+ return fmt.Errorf("seo_audit.max_posts must be non-negative")
+ }
+ }
// Validate link checker config
if cfg.LinkChecker.Enabled {
diff --git a/internal/newsletter/service.go b/internal/newsletter/service.go
index 757ec11..3d5de1f 100644
--- a/internal/newsletter/service.go
+++ b/internal/newsletter/service.go
@@ -191,10 +191,10 @@ func buildPrompt(feedItems []feedItem, redditItems []reddit.Post) (string, []str
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("3) Community context: start with a line like \"Here's what's buzzing in the community\" and include 2-3 items with context. Each item must include the original link plus 1 additional contextual link (official docs, project repo, or Kubernetes docs) where relevant.\n")
+ b.WriteString("4) More Blog Notes: if there are additional blog posts this week, include them as short notes WITH LINKS.\n")
b.WriteString("5) Conclusion with 'food for thought' questions to encourage replies\n")
- b.WriteString("Use concise headings and include links.\n\n")
+ b.WriteString("Use concise headings and include links throughout.\n\n")
var sources []string
b.WriteString("Blog posts:\n")
diff --git a/internal/seo/content.go b/internal/seo/content.go
index 00363f4..1fd1221 100644
--- a/internal/seo/content.go
+++ b/internal/seo/content.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"regexp"
+ "sort"
"strconv"
"strings"
"unicode"
@@ -36,6 +37,7 @@ func (cb *ContentBuilder) BuildOptimizedContent(
content := original
originalHasFAQ := cb.hasFAQBlock(original)
originalHasBlocks := cb.hasGutenbergBlocks(original)
+ unplacedLinks := make([]models.InternalLink, 0)
cb.logger.Info("Building optimized content",
"changes", len(optimization.ContentOptimization.Changes),
@@ -86,7 +88,12 @@ func (cb *ContentBuilder) BuildOptimizedContent(
"anchor", link.AnchorText,
)
- content = cb.insertInternalLink(content, link)
+ updated, inserted := cb.insertInternalLink(content, link)
+ if inserted {
+ content = updated
+ } else {
+ unplacedLinks = append(unplacedLinks, link)
+ }
}
// 4. Convert to Gutenberg blocks if original content was classic
@@ -94,19 +101,42 @@ func (cb *ContentBuilder) BuildOptimizedContent(
content = cb.wrapInGutenbergBlocks(content)
}
- // 5. Append FAQ block if should_create = true and no existing FAQ block
- if optimization.FAQBlock.ShouldCreate && len(optimization.FAQBlock.Questions) > 0 && !originalHasFAQ {
- cb.logger.Info("Adding FAQ block",
- "questions", len(optimization.FAQBlock.Questions),
- )
+ // 5. Ensure table blocks are properly structured for Gutenberg
+ content = cb.ensureTableBlocks(content)
+ // 6. Update or append FAQ block (never add a second FAQ section)
+ if optimization.FAQBlock.ShouldCreate && len(optimization.FAQBlock.Questions) > 0 {
headingLevel := detectHeadingLevel(content)
faqBlock := cb.buildFAQBlock(&optimization.FAQBlock, headingLevel)
- content = content + "\n\n" + faqBlock
+ if originalHasFAQ {
+ updated, replaced := cb.replaceFAQBlock(content, faqBlock)
+ if replaced {
+ content = updated
+ cb.logger.Info("Updated existing FAQ block",
+ "questions", len(optimization.FAQBlock.Questions),
+ )
+ } else {
+ cb.logger.Info("Failed to replace existing FAQ block; leaving original")
+ }
+ } else {
+ cb.logger.Info("Adding FAQ block",
+ "questions", len(optimization.FAQBlock.Questions),
+ )
+ content = content + "\n\n" + faqBlock
+ }
} else if originalHasFAQ {
cb.logger.Info("Skipping FAQ block (existing FAQ detected)")
}
+ // 7. Append Related Posts section for links that couldn't be placed in context
+ if len(unplacedLinks) > 0 {
+ headingLevel := detectHeadingLevel(content)
+ related := cb.buildRelatedPostsSection(unplacedLinks, headingLevel)
+ if related != "" {
+ content = content + "\n\n" + related
+ }
+ }
+
cb.logger.Info("Content optimization complete")
return content, nil
@@ -175,7 +205,7 @@ func (cb *ContentBuilder) fixBrokenLink(content string, broken models.BrokenLink
}
// insertInternalLink inserts an internal link at the appropriate position
-func (cb *ContentBuilder) insertInternalLink(content string, link models.InternalLink) string {
+func (cb *ContentBuilder) insertInternalLink(content string, link models.InternalLink) (string, bool) {
// Build the link HTML
linkHTML := fmt.Sprintf(`%s`, link.TargetSlug, link.AnchorText)
@@ -190,7 +220,7 @@ func (cb *ContentBuilder) insertInternalLink(content string, link models.Interna
// Find the end of the current sentence/paragraph
endIdx := findSentenceEnd(parts[1])
if endIdx > 0 {
- return parts[0] + link.InsertionContext + parts[1][:endIdx] + " " + linkHTML + parts[1][endIdx:]
+ return parts[0] + link.InsertionContext + parts[1][:endIdx] + " " + linkHTML + parts[1][endIdx:], true
}
}
}
@@ -199,18 +229,14 @@ func (cb *ContentBuilder) insertInternalLink(content string, link models.Interna
remainder := content[end:]
endIdx := findSentenceEnd(remainder)
if endIdx > 0 {
- return content[:end] + remainder[:endIdx] + " " + linkHTML + remainder[endIdx:]
+ return content[:end] + remainder[:endIdx] + " " + linkHTML + remainder[endIdx:], true
}
- return content[:end] + " " + linkHTML + content[end:]
+ return content[:end] + " " + linkHTML + content[end:], true
}
// Fallback: try to insert the link by replacing the anchor text if it appears in content
if strings.Contains(content, link.AnchorText) && !cb.isInsideTag(content, link.AnchorText) {
- return strings.Replace(content, link.AnchorText, linkHTML, 1)
- }
-
- if updated, ok := insertAfterFirstParagraph(content, linkHTML); ok {
- return updated
+ return strings.Replace(content, link.AnchorText, linkHTML, 1), true
}
cb.logger.Warn("Could not find appropriate insertion point for internal link",
@@ -218,7 +244,7 @@ func (cb *ContentBuilder) insertInternalLink(content string, link models.Interna
"context", truncate(link.InsertionContext, 100),
)
- return content
+ return content, false
}
// isInsideTag checks if text is already inside an HTML tag
@@ -397,7 +423,7 @@ func (cb *ContentBuilder) wrapInGutenbergBlocks(content string) string {
return content
}
- blockRe := regexp.MustCompile(`(?is)(]*>.*?||]*>.*?
|]*>.*?
)`)
+ blockRe := regexp.MustCompile(`(?is)(]*>.*?||]*>.*?
|]*>.*?
|]*wp-block-table[^>]*>.*?|)`)
matches := blockRe.FindAllStringIndex(trimmed, -1)
if len(matches) == 0 {
parts := splitParagraphs(trimmed)
@@ -471,6 +497,14 @@ func wrapHTMLBlock(block string) string {
return fmt.Sprintf("\n%s\n", block)
}
+ if strings.HasPrefix(strings.ToLower(block), "\n%s\n", block)
+ }
+
+ if strings.HasPrefix(strings.ToLower(block), "\n%s\n", block)
+ }
+
return fmt.Sprintf("\n%s
\n", block)
}
@@ -526,6 +560,113 @@ func splitParagraphs(content string) []string {
return re.Split(content, -1)
}
+func (cb *ContentBuilder) ensureTableBlocks(content string) string {
+ if strings.TrimSpace(content) == "" {
+ return content
+ }
+
+ type match struct {
+ start int
+ end int
+ kind string
+ }
+
+ var matches []match
+ figureRe := regexp.MustCompile(`(?is)]*wp-block-table[^>]*>.*?`)
+ tableRe := regexp.MustCompile(`(?is)`)
+
+ for _, loc := range figureRe.FindAllStringIndex(content, -1) {
+ matches = append(matches, match{start: loc[0], end: loc[1], kind: "figure"})
+ }
+ for _, loc := range tableRe.FindAllStringIndex(content, -1) {
+ matches = append(matches, match{start: loc[0], end: loc[1], kind: "table"})
+ }
+
+ if len(matches) == 0 {
+ return content
+ }
+
+ sort.Slice(matches, func(i, j int) bool {
+ if matches[i].start == matches[j].start {
+ return matches[i].end > matches[j].end
+ }
+ return matches[i].start < matches[j].start
+ })
+
+ var b strings.Builder
+ last := 0
+ for _, m := range matches {
+ if m.start < last {
+ continue
+ }
+ b.WriteString(content[last:m.start])
+
+ block := content[m.start:m.end]
+ openIdx := strings.LastIndex(content[:m.start], "\n%s\n", block))
+ case "table":
+ lower := strings.ToLower(block)
+ if strings.Contains(lower, "wp-block-table") {
+ b.WriteString(block)
+ } else {
+ b.WriteString(fmt.Sprintf("\n%s\n", block))
+ }
+ default:
+ b.WriteString(block)
+ }
+ last = m.end
+ }
+ b.WriteString(content[last:])
+ return b.String()
+}
+
+func (cb *ContentBuilder) replaceFAQBlock(content, replacement string) (string, bool) {
+ if strings.TrimSpace(content) == "" || strings.TrimSpace(replacement) == "" {
+ return content, false
+ }
+ re := regexp.MustCompile(`(?s).*?`)
+ if !re.MatchString(content) {
+ return content, false
+ }
+ updated := re.ReplaceAllString(content, replacement)
+ return updated, true
+}
+
+func (cb *ContentBuilder) buildRelatedPostsSection(links []models.InternalLink, headingLevel int) string {
+ if len(links) == 0 {
+ return ""
+ }
+ if headingLevel < 1 || headingLevel > 6 {
+ headingLevel = 2
+ }
+
+ var b strings.Builder
+ b.WriteString(fmt.Sprintf("\nRelated posts\n\n\n", headingLevel, headingLevel, headingLevel))
+ b.WriteString("\n")
+ for _, link := range links {
+ anchor := strings.TrimSpace(link.AnchorText)
+ if anchor == "" {
+ anchor = strings.TrimSpace(link.TargetSlug)
+ }
+ if anchor == "" {
+ continue
+ }
+ b.WriteString(fmt.Sprintf("- %s
", link.TargetSlug, anchor))
+ }
+ b.WriteString("
\n")
+ return b.String()
+}
+
// normalizeText normalizes text for comparison (handles whitespace, HTML entities)
func normalizeText(text string) string {
// Normalize whitespace
diff --git a/internal/seo/content_test.go b/internal/seo/content_test.go
index b38957b..3055788 100644
--- a/internal/seo/content_test.go
+++ b/internal/seo/content_test.go
@@ -74,3 +74,47 @@ func TestWrapInGutenbergBlocksHandlesHeadingsAndLists(t *testing.T) {
t.Fatalf("expected 1 paragraph block, got %d", count)
}
}
+
+func TestReplaceFAQBlockUpdatesExistingFAQ(t *testing.T) {
+ builder := NewContentBuilder(nil)
+ original := ``
+ replacement := ``
+
+ updated, replaced := builder.replaceFAQBlock(original, replacement)
+ if !replaced {
+ t.Fatalf("expected FAQ block to be replaced")
+ }
+ if strings.Count(updated, "wp:rank-math/faq-block") != 2 {
+ t.Fatalf("expected single FAQ block after replacement")
+ }
+ if !strings.Contains(updated, "faq-question-1") {
+ t.Fatalf("expected replacement content to be present")
+ }
+}
+
+func TestEnsureTableBlocksWrapsBareTable(t *testing.T) {
+ builder := NewContentBuilder(nil)
+ content := "Intro
\n\nOutro
"
+ updated := builder.ensureTableBlocks(content)
+ if !strings.Contains(updated, "") {
+ t.Fatalf("expected table block wrapper")
+ }
+ if !strings.Contains(updated, "wp-block-table") {
+ t.Fatalf("expected table figure wrapper")
+ }
+}
+
+func TestBuildRelatedPostsSection(t *testing.T) {
+ builder := NewContentBuilder(nil)
+ links := []models.InternalLink{
+ {AnchorText: "Post One", TargetSlug: "post-one"},
+ {AnchorText: "Post Two", TargetSlug: "post-two"},
+ }
+ section := builder.buildRelatedPostsSection(links, 2)
+ if !strings.Contains(section, "Related posts") {
+ t.Fatalf("expected related posts heading")
+ }
+ if !strings.Contains(section, "post-one") || !strings.Contains(section, "post-two") {
+ t.Fatalf("expected related post links")
+ }
+}
diff --git a/internal/seo/optimizer.go b/internal/seo/optimizer.go
index 303b4d9..d5e7982 100644
--- a/internal/seo/optimizer.go
+++ b/internal/seo/optimizer.go
@@ -31,6 +31,12 @@ type Optimizer struct {
logger *logger.Logger
}
+// OptimizeRunOptions defines optional behavior for optimization runs.
+type OptimizeRunOptions struct {
+ CheckCooldown bool
+ Feedback string
+}
+
// SkipError indicates a deliberate skip (not a failure).
type SkipError struct {
Reason string
@@ -99,7 +105,9 @@ func (o *Optimizer) OptimizeNextPost(ctx context.Context, language string) error
"slug", post.Slug,
"modified", post.Modified,
)
- err = o.optimizePost(ctx, post, language, false)
+ err = o.optimizePost(ctx, post, language, &OptimizeRunOptions{
+ CheckCooldown: false,
+ })
var skipErr *SkipError
if err != nil && !errors.As(err, &skipErr) {
return err
@@ -137,7 +145,9 @@ func (o *Optimizer) OptimizeSpecificPost(ctx context.Context, postID int, langua
"slug", post.Slug,
)
- err = o.optimizePost(ctx, post, language, true)
+ err = o.optimizePost(ctx, post, language, &OptimizeRunOptions{
+ CheckCooldown: true,
+ })
var skipErr *SkipError
if err != nil && !errors.As(err, &skipErr) {
return err
@@ -152,7 +162,25 @@ func (o *Optimizer) OptimizeSpecificPost(ctx context.Context, postID int, langua
return err
}
-func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, language string, checkCooldown bool) error {
+// OptimizeSpecificPostWithFeedback regenerates a draft using explicit feedback.
+func (o *Optimizer) OptimizeSpecificPostWithFeedback(ctx context.Context, postID int, language, feedback string) error {
+ o.logger.Info("Starting feedback optimization",
+ "post_id", postID,
+ "language", language,
+ )
+
+ post, err := o.wpClient.GetPost(ctx, postID, language)
+ if err != nil {
+ return fmt.Errorf("failed to fetch post: %w", err)
+ }
+
+ return o.optimizePost(ctx, post, language, &OptimizeRunOptions{
+ CheckCooldown: false,
+ Feedback: feedback,
+ })
+}
+
+func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, language string, options *OptimizeRunOptions) error {
if post == nil {
return fmt.Errorf("post is nil")
}
@@ -166,6 +194,7 @@ func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, lang
return fmt.Errorf("post is not published (status: %s)", post.Status)
}
+ checkCooldown := options == nil || options.CheckCooldown
if checkCooldown && o.wasRecentlyOptimized(post, o.config.Optimization.CooldownDays) {
reason := "post optimized within cooldown window"
o.logger.Info("Skipping optimization",
@@ -224,7 +253,13 @@ func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, lang
"slug", post.Slug,
)
- optimization, err := o.llmClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap)
+ feedback := ""
+ if options != nil {
+ feedback = options.Feedback
+ }
+ optimization, err := o.llmClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap, &agent.OptimizeOptions{
+ Feedback: feedback,
+ })
if err != nil {
return fmt.Errorf("llm optimization failed: %w", err)
}
@@ -367,7 +402,9 @@ func (o *Optimizer) optimizeTranslationPosts(ctx context.Context, basePost *word
continue
}
- if err := o.optimizePost(ctx, translationPost, target.Language, true); err != nil {
+ if err := o.optimizePost(ctx, translationPost, target.Language, &OptimizeRunOptions{
+ CheckCooldown: true,
+ }); err != nil {
var skipErr *SkipError
if errors.As(err, &skipErr) {
continue
diff --git a/internal/seoaudit/service.go b/internal/seoaudit/service.go
new file mode 100644
index 0000000..bf53897
--- /dev/null
+++ b/internal/seoaudit/service.go
@@ -0,0 +1,336 @@
+package seoaudit
+
+import (
+ "context"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "golang.org/x/net/html"
+
+ "seo-optimizer/internal/config"
+ "seo-optimizer/internal/logger"
+ "seo-optimizer/internal/storage"
+ "seo-optimizer/internal/wordpress"
+ "seo-optimizer/pkg/models"
+)
+
+type Service struct {
+ wp *wordpress.Client
+ store *storage.SEOAuditStorage
+ logger *logger.Logger
+ baseURL *url.URL
+ maxPosts int
+}
+
+// NewService creates an SEO audit service.
+func NewService(wp *wordpress.Client, store *storage.SEOAuditStorage, cfg config.SEOAuditConfig, log *logger.Logger) (*Service, error) {
+ if log == nil {
+ log = logger.NewDefaultLogger()
+ }
+ baseURL, err := url.Parse(strings.TrimSpace(cfg.BaseURL))
+ if err != nil {
+ return nil, err
+ }
+ return &Service{
+ wp: wp,
+ store: store,
+ logger: log.WithComponent("seo_audit"),
+ baseURL: baseURL,
+ maxPosts: cfg.MaxPosts,
+ }, nil
+}
+
+// RunScan scans posts and stores a report.
+func (s *Service) RunScan(ctx context.Context) (*models.SEOAuditReport, error) {
+ report, err := s.scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if s.store != nil {
+ if err := s.store.Save(report); err != nil {
+ return report, err
+ }
+ }
+ return report, nil
+}
+
+// LatestReport returns the latest stored report.
+func (s *Service) LatestReport() (*models.SEOAuditReport, error) {
+ if s.store == nil {
+ return nil, nil
+ }
+ return s.store.Latest()
+}
+
+func (s *Service) scan(ctx context.Context) (*models.SEOAuditReport, error) {
+ start := time.Now()
+ perPage := 100
+ page := 1
+ processed := 0
+ posts := make([]models.PostSEOAudit, 0)
+
+ for {
+ if s.maxPosts > 0 && processed >= s.maxPosts {
+ break
+ }
+ params := wordpress.ListParams{
+ Status: "publish",
+ OrderBy: "modified",
+ Order: "desc",
+ PerPage: perPage,
+ Page: page,
+ }
+ items, err := s.wp.ListPosts(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ if len(items) == 0 {
+ break
+ }
+
+ for _, post := range items {
+ if s.maxPosts > 0 && processed >= s.maxPosts {
+ break
+ }
+ processed += 1
+ audit := s.scorePost(post)
+ posts = append(posts, audit)
+ }
+
+ if len(items) < perPage {
+ break
+ }
+ page += 1
+ }
+
+ sort.Slice(posts, func(i, j int) bool {
+ if posts[i].Score == posts[j].Score {
+ return posts[i].PostID < posts[j].PostID
+ }
+ return posts[i].Score < posts[j].Score
+ })
+
+ return &models.SEOAuditReport{
+ CreatedAt: time.Now(),
+ DurationMs: time.Since(start).Milliseconds(),
+ TotalPosts: processed,
+ Posts: posts,
+ }, nil
+}
+
+func (s *Service) scorePost(post *wordpress.Post) models.PostSEOAudit {
+ seoTitle := getMetaString(post.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
+ if strings.TrimSpace(seoTitle) == "" {
+ seoTitle = post.Title.Rendered
+ }
+ seoDesc := getMetaString(post.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
+
+ titleScore, titleIssues := scoreTitle(seoTitle)
+ descScore, descIssues := scoreDescription(seoDesc)
+
+ internalCount, externalCount := s.countLinks(post.Content.Rendered)
+ internalScore, internalIssues := scoreInternalLinks(internalCount)
+ externalScore, externalIssues := scoreExternalLinks(externalCount)
+
+ score := aggregateScore(titleScore, descScore, internalScore, externalScore)
+ issues := buildIssues(titleIssues, descIssues, internalIssues, externalIssues)
+
+ return models.PostSEOAudit{
+ PostID: post.ID,
+ PostTitle: post.Title.Rendered,
+ PostURL: post.Link,
+ Score: score,
+ TitleScore: titleScore,
+ DescriptionScore: descScore,
+ InternalLinkScore: internalScore,
+ ExternalLinkScore: externalScore,
+ InternalLinkCount: internalCount,
+ ExternalLinkCount: externalCount,
+ TitleIssues: titleIssues,
+ DescriptionIssues: descIssues,
+ InternalLinkIssues: internalIssues,
+ ExternalLinkIssues: externalIssues,
+ Issues: issues,
+ }
+}
+
+func (s *Service) countLinks(content string) (int, int) {
+ if strings.TrimSpace(content) == "" {
+ return 0, 0
+ }
+ root, err := html.Parse(strings.NewReader(content))
+ if err != nil {
+ return 0, 0
+ }
+ internal := 0
+ external := 0
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Data == "a" {
+ for _, attr := range n.Attr {
+ if strings.ToLower(attr.Key) != "href" {
+ continue
+ }
+ href := strings.TrimSpace(attr.Val)
+ if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(href, "mailto:") || strings.HasPrefix(href, "tel:") {
+ continue
+ }
+ if s.isInternalLink(href) {
+ internal += 1
+ } else {
+ external += 1
+ }
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(root)
+ return internal, external
+}
+
+func (s *Service) isInternalLink(link string) bool {
+ if strings.HasPrefix(link, "/") {
+ return true
+ }
+ parsed, err := url.Parse(link)
+ if err != nil || parsed.Host == "" {
+ return false
+ }
+ base := s.baseURL
+ if base == nil || base.Host == "" {
+ return false
+ }
+ return strings.EqualFold(parsed.Host, base.Host)
+}
+
+func aggregateScore(title, desc, internal, external int) int {
+ score := 0.3*float64(title) + 0.3*float64(desc) + 0.2*float64(internal) + 0.2*float64(external)
+ return int(score + 0.5)
+}
+
+func buildIssues(title, desc, internal, external []string) []models.SEOAuditIssue {
+ issues := make([]models.SEOAuditIssue, 0)
+ for _, item := range title {
+ issues = append(issues, models.SEOAuditIssue{Type: "title", Details: item})
+ }
+ for _, item := range desc {
+ issues = append(issues, models.SEOAuditIssue{Type: "description", Details: item})
+ }
+ for _, item := range internal {
+ issues = append(issues, models.SEOAuditIssue{Type: "internal_links", Details: item})
+ }
+ for _, item := range external {
+ issues = append(issues, models.SEOAuditIssue{Type: "external_links", Details: item})
+ }
+ return issues
+}
+
+func scoreTitle(title string) (int, []string) {
+ trimmed := strings.TrimSpace(title)
+ if trimmed == "" {
+ return 0, []string{"missing"}
+ }
+ length := len([]rune(trimmed))
+ issues := make([]string, 0)
+ score := 100
+ if length < 30 {
+ score = 40
+ issues = append(issues, "too_short")
+ } else if length < 40 {
+ score = 60
+ issues = append(issues, "short")
+ } else if length < 50 {
+ score = 80
+ issues = append(issues, "could_be_longer")
+ } else if length <= 60 {
+ score = 100
+ } else if length <= 70 {
+ score = 80
+ issues = append(issues, "slightly_long")
+ } else if length <= 80 {
+ score = 60
+ issues = append(issues, "long")
+ } else {
+ score = 40
+ issues = append(issues, "too_long")
+ }
+ return score, issues
+}
+
+func scoreDescription(desc string) (int, []string) {
+ trimmed := strings.TrimSpace(desc)
+ if trimmed == "" {
+ return 0, []string{"missing"}
+ }
+ length := len([]rune(trimmed))
+ issues := make([]string, 0)
+ score := 100
+ if length < 90 {
+ score = 40
+ issues = append(issues, "too_short")
+ } else if length < 120 {
+ score = 60
+ issues = append(issues, "short")
+ } else if length < 150 {
+ score = 80
+ issues = append(issues, "could_be_longer")
+ } else if length <= 160 {
+ score = 100
+ } else if length <= 180 {
+ score = 80
+ issues = append(issues, "slightly_long")
+ } else if length <= 200 {
+ score = 60
+ issues = append(issues, "long")
+ } else {
+ score = 40
+ issues = append(issues, "too_long")
+ }
+ return score, issues
+}
+
+func scoreInternalLinks(count int) (int, []string) {
+ switch {
+ case count >= 3 && count <= 8:
+ return 100, nil
+ case count == 2 || (count >= 9 && count <= 10):
+ return 80, []string{"off_target"}
+ case count == 1 || (count >= 11 && count <= 12):
+ return 60, []string{"low_quality"}
+ case count == 0:
+ return 40, []string{"missing"}
+ default:
+ return 40, []string{"too_many"}
+ }
+}
+
+func scoreExternalLinks(count int) (int, []string) {
+ switch {
+ case count >= 1 && count <= 5:
+ return 100, nil
+ case count == 0:
+ return 40, []string{"missing"}
+ case count >= 6 && count <= 10:
+ return 80, []string{"many"}
+ default:
+ return 60, []string{"too_many"}
+ }
+}
+
+func getMetaString(meta map[string]interface{}, keys ...string) string {
+ if meta == nil {
+ return ""
+ }
+ for _, key := range keys {
+ if value, ok := meta[key]; ok && value != nil {
+ if str, ok := value.(string); ok && str != "" {
+ return str
+ }
+ }
+ }
+ return ""
+}
diff --git a/internal/storage/seo_audit_storage.go b/internal/storage/seo_audit_storage.go
new file mode 100644
index 0000000..f1072df
--- /dev/null
+++ b/internal/storage/seo_audit_storage.go
@@ -0,0 +1,128 @@
+package storage
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "seo-optimizer/internal/logger"
+ "seo-optimizer/pkg/models"
+)
+
+// SEOAuditStorage manages file-based storage of SEO audit reports.
+type SEOAuditStorage struct {
+ basePath string
+ logger *logger.Logger
+}
+
+// NewSEOAuditStorage creates a new SEO audit storage instance.
+func NewSEOAuditStorage(basePath string, log *logger.Logger) *SEOAuditStorage {
+ if log == nil {
+ log = logger.NewDefaultLogger()
+ }
+
+ return &SEOAuditStorage{
+ basePath: basePath,
+ logger: log.WithComponent("seo_audit_storage"),
+ }
+}
+
+// Initialize creates the storage directory.
+func (s *SEOAuditStorage) Initialize() error {
+ if err := os.MkdirAll(s.basePath, 0755); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", s.basePath, err)
+ }
+ return nil
+}
+
+// Save stores an SEO audit report.
+func (s *SEOAuditStorage) Save(report *models.SEOAuditReport) error {
+ if report == nil {
+ return fmt.Errorf("report is nil")
+ }
+ if report.CreatedAt.IsZero() {
+ report.CreatedAt = time.Now()
+ }
+ if report.ID == "" {
+ report.ID = fmt.Sprintf("%d", report.CreatedAt.Unix())
+ }
+
+ filename := fmt.Sprintf("%s_%d.json", report.ID, report.CreatedAt.Unix())
+ path := filepath.Join(s.basePath, filename)
+
+ data, err := json.MarshalIndent(report, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal report: %w", err)
+ }
+
+ if err := os.WriteFile(path, data, 0644); err != nil {
+ return fmt.Errorf("failed to write report: %w", err)
+ }
+
+ s.logger.Debug("SEO audit report saved", "file", path)
+ return nil
+}
+
+// List returns reports sorted by newest first. Limit <= 0 returns all.
+func (s *SEOAuditStorage) List(limit int) ([]*models.SEOAuditReport, error) {
+ files, err := os.ReadDir(s.basePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []*models.SEOAuditReport{}, nil
+ }
+ return nil, fmt.Errorf("failed to read directory: %w", err)
+ }
+
+ var reports []*models.SEOAuditReport
+ for _, file := range files {
+ if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
+ continue
+ }
+ path := filepath.Join(s.basePath, file.Name())
+ report, err := s.readReport(path)
+ if err != nil {
+ s.logger.Warn("Failed to read SEO audit report", "file", path, "error", err)
+ continue
+ }
+ reports = append(reports, report)
+ }
+
+ sort.Slice(reports, func(i, j int) bool {
+ return reports[i].CreatedAt.After(reports[j].CreatedAt)
+ })
+
+ if limit > 0 && len(reports) > limit {
+ reports = reports[:limit]
+ }
+
+ return reports, nil
+}
+
+// Latest returns the most recent report.
+func (s *SEOAuditStorage) Latest() (*models.SEOAuditReport, error) {
+ reports, err := s.List(1)
+ if err != nil {
+ return nil, err
+ }
+ if len(reports) == 0 {
+ return nil, nil
+ }
+ return reports[0], nil
+}
+
+func (s *SEOAuditStorage) readReport(path string) (*models.SEOAuditReport, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var report models.SEOAuditReport
+ if err := json.Unmarshal(data, &report); err != nil {
+ return nil, err
+ }
+
+ return &report, nil
+}
diff --git a/pkg/models/seo_audit.go b/pkg/models/seo_audit.go
new file mode 100644
index 0000000..3de862c
--- /dev/null
+++ b/pkg/models/seo_audit.go
@@ -0,0 +1,37 @@
+package models
+
+import "time"
+
+// SEOAuditIssue captures a single issue detected for a post.
+type SEOAuditIssue struct {
+ Type string `json:"type"`
+ Details string `json:"details"`
+}
+
+// PostSEOAudit summarizes SEO signals for a single post.
+type PostSEOAudit struct {
+ PostID int `json:"post_id"`
+ PostTitle string `json:"post_title"`
+ PostURL string `json:"post_url"`
+ Score int `json:"score"`
+ TitleScore int `json:"title_score"`
+ DescriptionScore int `json:"description_score"`
+ InternalLinkScore int `json:"internal_link_score"`
+ ExternalLinkScore int `json:"external_link_score"`
+ InternalLinkCount int `json:"internal_link_count"`
+ ExternalLinkCount int `json:"external_link_count"`
+ TitleIssues []string `json:"title_issues"`
+ DescriptionIssues []string `json:"description_issues"`
+ InternalLinkIssues []string `json:"internal_link_issues"`
+ ExternalLinkIssues []string `json:"external_link_issues"`
+ Issues []SEOAuditIssue `json:"issues"`
+}
+
+// SEOAuditReport is a snapshot of SEO scores across posts.
+type SEOAuditReport struct {
+ ID string `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ DurationMs int64 `json:"duration_ms"`
+ TotalPosts int `json:"total_posts"`
+ Posts []PostSEOAudit `json:"posts"`
+}