This commit is contained in:
2026-01-27 22:28:35 +01:00
parent 0f9a0d6568
commit d768402868
19 changed files with 1489 additions and 31 deletions
+16 -1
View File
@@ -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() {
+13
View File
@@ -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
+5
View File
@@ -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
+6 -1
View File
@@ -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)
+6 -1
View File
@@ -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)
+6
View File
@@ -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
}
+11
View File
@@ -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 <!-- wp:table --> and <figure class="wp-block-table"><table>...</table></figure>.
- 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),
}
}
+480 -1
View File
@@ -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], "<!-- wp:")
if openIdx == -1 {
return ""
}
closeIdx := strings.Index(content[idx:], "<!-- /wp:")
if closeIdx == -1 {
return ""
}
closeIdx += idx
endIdx := strings.Index(content[closeIdx:], "-->")
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) {
+125 -1
View File
@@ -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 = `
<h3>${post.post_title || "Untitled"}</h3>
<div class="meta">Post ID: ${post.post_id} Score: ${post.score}</div>
<div class="meta">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})</div>
${post.post_url ? `<div class="actions"><a href="${post.post_url}" target="_blank" rel="noreferrer">Open post</a></div>` : ""}
${issues ? `<div class="meta">Issues: ${issues}</div>` : ""}
`;
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 <id>], approve <id>, reject <id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], broken-links [scan], image-audit [scan|fix <post_id>|sync <post_id> [lang]], orphans, translate <post_id> [lang], optimize <post_id> [lang], changes <post_id>, status <job_id>, audit [limit]",
"Commands: health, version, pending [summary|text <id>], approve <id>, reject <id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], broken-links [scan], image-audit [scan|fix <post_id>|sync <post_id> [lang]], seo-audit [scan], search <text> [--case] [--exact] [--regex] [--block] [--max=N] [--posts=N], orphans, translate <post_id> [lang], optimize <post_id> [lang], changes <post_id>, status <job_id>, audit [limit]",
health: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
version: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
pending: async (args) => {
@@ -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 <text> [--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 <post_id> [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);
+12
View File
@@ -143,6 +143,18 @@
<div id="image-audit-featured-list" class="list"></div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h3>SEO Audit</h3>
<div class="panel-actions">
<button id="scan-seo-audit">Scan</button>
<button id="refresh-seo-audit">Refresh</button>
</div>
</div>
<div id="seo-audit-summary" class="meta muted"></div>
<div id="seo-audit-list" class="list"></div>
</div>
</div>
</section>
+30
View File
@@ -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;
+30
View File
@@ -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 {
+3 -3
View File
@@ -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")
+159 -18
View File
@@ -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(`<a href="/%s/">%s</a>`, 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)(<h[1-6][^>]*>.*?</h[1-6]>|<ul[^>]*>.*?</ul>|<ol[^>]*>.*?</ol>|<p[^>]*>.*?</p>)`)
blockRe := regexp.MustCompile(`(?is)(<h[1-6][^>]*>.*?</h[1-6]>|<ul[^>]*>.*?</ul>|<ol[^>]*>.*?</ol>|<p[^>]*>.*?</p>|<figure[^>]*wp-block-table[^>]*>.*?</figure>|<table[^>]*>.*?</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("<!-- wp:heading -->\n%s\n<!-- /wp:heading -->", block)
}
if strings.HasPrefix(strings.ToLower(block), "<figure") && strings.Contains(strings.ToLower(block), "wp-block-table") {
return fmt.Sprintf("<!-- wp:table -->\n%s\n<!-- /wp:table -->", block)
}
if strings.HasPrefix(strings.ToLower(block), "<table") {
return fmt.Sprintf("<!-- wp:table -->\n<figure class=\"wp-block-table\">%s</figure>\n<!-- /wp:table -->", block)
}
return fmt.Sprintf("<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", 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)<figure[^>]*wp-block-table[^>]*>.*?</figure>`)
tableRe := regexp.MustCompile(`(?is)<table[^>]*>.*?</table>`)
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], "<!-- wp:table")
closeIdx := strings.LastIndex(content[:m.start], "<!-- /wp:table")
insideTableBlock := openIdx != -1 && (closeIdx == -1 || openIdx > closeIdx)
if insideTableBlock {
b.WriteString(block)
last = m.end
continue
}
switch m.kind {
case "figure":
b.WriteString(fmt.Sprintf("<!-- wp:table -->\n%s\n<!-- /wp:table -->", block))
case "table":
lower := strings.ToLower(block)
if strings.Contains(lower, "wp-block-table") {
b.WriteString(block)
} else {
b.WriteString(fmt.Sprintf("<!-- wp:table -->\n<figure class=\"wp-block-table\">%s</figure>\n<!-- /wp:table -->", 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)<!--\s*wp:rank-math/faq-block\b[^>]*-->.*?<!--\s*/wp:rank-math/faq-block\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("<!-- wp:heading {\"level\":%d} -->\n<h%d>Related posts</h%d>\n<!-- /wp:heading -->\n\n", headingLevel, headingLevel, headingLevel))
b.WriteString("<!-- wp:list -->\n<ul>")
for _, link := range links {
anchor := strings.TrimSpace(link.AnchorText)
if anchor == "" {
anchor = strings.TrimSpace(link.TargetSlug)
}
if anchor == "" {
continue
}
b.WriteString(fmt.Sprintf("<li><a href=\"/%s/\">%s</a></li>", link.TargetSlug, anchor))
}
b.WriteString("</ul>\n<!-- /wp:list -->")
return b.String()
}
// normalizeText normalizes text for comparison (handles whitespace, HTML entities)
func normalizeText(text string) string {
// Normalize whitespace
+44
View File
@@ -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 := `<!-- wp:rank-math/faq-block {"questions":[]} --><div class="wp-block-rank-math-faq-block"></div><!-- /wp:rank-math/faq-block -->`
replacement := `<!-- wp:rank-math/faq-block {"questions":[{"id":"faq-question-1"}]} --><div class="wp-block-rank-math-faq-block"><div class="rank-math-faq-item"><h3 class="rank-math-question">Q</h3><div class="rank-math-answer">A</div></div></div><!-- /wp:rank-math/faq-block -->`
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 := "<p>Intro</p>\n<table><tr><td>A</td></tr></table>\n<p>Outro</p>"
updated := builder.ensureTableBlocks(content)
if !strings.Contains(updated, "<!-- wp:table -->") {
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")
}
}
+42 -5
View File
@@ -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
+336
View File
@@ -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 ""
}
+128
View File
@@ -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
}
+37
View File
@@ -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"`
}