From d7684028687f2215da00467f1f56a915dab0bdff Mon Sep 17 00:00:00 2001 From: Alexandre Date: Tue, 27 Jan 2026 22:28:35 +0100 Subject: [PATCH] +1 --- cmd/optimizer/main.go | 17 +- configs/config.example.yaml | 13 + deployments/kubernetes/deployment.yaml | 5 + internal/agent/claude.go | 7 +- internal/agent/deepseek.go | 7 +- internal/agent/llm.go | 6 + internal/agent/prompts.go | 11 + internal/api/server.go | 481 ++++++++++++++++++++++++- internal/api/ui/app.js | 126 ++++++- internal/api/ui/index.html | 12 + internal/api/ui/styles.css | 30 ++ internal/config/config.go | 30 ++ internal/newsletter/service.go | 6 +- internal/seo/content.go | 177 ++++++++- internal/seo/content_test.go | 44 +++ internal/seo/optimizer.go | 47 ++- internal/seoaudit/service.go | 336 +++++++++++++++++ internal/storage/seo_audit_storage.go | 128 +++++++ pkg/models/seo_audit.go | 37 ++ 19 files changed, 1489 insertions(+), 31 deletions(-) create mode 100644 internal/seoaudit/service.go create mode 100644 internal/storage/seo_audit_storage.go create mode 100644 pkg/models/seo_audit.go 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 ? `
Open post
` : ""} + ${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 @@ + +
+
+

SEO Audit

+
+ + +
+
+
+
+
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 := `

Q

A
` + + 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
A
\n

Outro

" + 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"` +}