+1
This commit is contained in:
+16
-1
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user