Files
WPSidekick/internal/seo/content.go
T
2026-01-25 08:57:55 +01:00

459 lines
13 KiB
Go

package seo
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"seo-optimizer/internal/logger"
"seo-optimizer/pkg/models"
)
// ContentBuilder applies all optimizations to post content
type ContentBuilder struct {
logger *logger.Logger
}
// NewContentBuilder creates a new content builder
func NewContentBuilder(log *logger.Logger) *ContentBuilder {
if log == nil {
log = logger.NewDefaultLogger()
}
return &ContentBuilder{
logger: log.WithComponent("content-builder"),
}
}
// BuildOptimizedContent applies all optimizations and returns the updated content
func (cb *ContentBuilder) BuildOptimizedContent(
original string,
optimization *models.OptimizationResponse,
) (string, error) {
content := original
originalHasFAQ := cb.hasFAQBlock(original)
originalHasBlocks := cb.hasGutenbergBlocks(original)
cb.logger.Info("Building optimized content",
"changes", len(optimization.ContentOptimization.Changes),
"links", len(optimization.ContentOptimization.InternalLinks),
"fact_checks", len(optimization.Validation.FactChecks),
"broken_links", len(optimization.Validation.BrokenLinks),
"faq", optimization.FAQBlock.ShouldCreate,
)
// 1. Apply content changes (keyword injection, heading improvements, etc.)
for i, change := range optimization.ContentOptimization.Changes {
cb.logger.Debug("Applying content change",
"index", i,
"type", change.Type,
"location", change.Location,
)
content = cb.applyChange(content, change)
}
// 2. Apply validation fixes (update outdated info, fix broken links)
for i, factCheck := range optimization.Validation.FactChecks {
if factCheck.Status == "outdated" && factCheck.UpdatedText != "" {
cb.logger.Debug("Applying fact check update",
"index", i,
"claim", truncate(factCheck.Claim, 100),
)
content = cb.applyFactCheckUpdate(content, factCheck)
}
}
for i, brokenLink := range optimization.Validation.BrokenLinks {
cb.logger.Debug("Fixing broken link",
"index", i,
"url", brokenLink.URL,
"action", brokenLink.Action,
)
content = cb.fixBrokenLink(content, brokenLink)
}
// 3. Insert internal links at appropriate positions
for i, link := range optimization.ContentOptimization.InternalLinks {
cb.logger.Debug("Inserting internal link",
"index", i,
"target_post_id", link.TargetPostID,
"anchor", link.AnchorText,
)
content = cb.insertInternalLink(content, link)
}
// 4. Convert to Gutenberg blocks if original content was classic
if !originalHasBlocks {
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),
)
headingLevel := detectHeadingLevel(content)
faqBlock := cb.buildFAQBlock(&optimization.FAQBlock, headingLevel)
content = content + "\n\n" + faqBlock
} else if originalHasFAQ {
cb.logger.Info("Skipping FAQ block (existing FAQ detected)")
}
cb.logger.Info("Content optimization complete")
return content, nil
}
// applyChange replaces original text with updated text
func (cb *ContentBuilder) applyChange(content string, change models.ContentChange) string {
// Try exact match first
if strings.Contains(content, change.Original) {
return strings.Replace(content, change.Original, change.Updated, 1)
}
// Try normalized match (handle HTML entities, extra whitespace)
normalizedOriginal := normalizeText(change.Original)
normalizedContent := normalizeText(content)
if strings.Contains(normalizedContent, normalizedOriginal) {
// Find position in normalized content
idx := strings.Index(normalizedContent, normalizedOriginal)
if idx >= 0 {
// Find corresponding position in original content
// This is approximate but should work for most cases
before := content[:idx]
after := content[idx+len(change.Original):]
return before + change.Updated + after
}
}
cb.logger.Warn("Could not find exact match for content change",
"original", truncate(change.Original, 100),
"type", change.Type,
)
return content
}
// applyFactCheckUpdate updates outdated technical information
func (cb *ContentBuilder) applyFactCheckUpdate(content string, factCheck models.FactCheck) string {
// Similar to applyChange but specific to fact checks
if strings.Contains(content, factCheck.Claim) {
return strings.Replace(content, factCheck.Claim, factCheck.UpdatedText, 1)
}
cb.logger.Warn("Could not find fact check claim in content",
"claim", truncate(factCheck.Claim, 100),
)
return content
}
// fixBrokenLink fixes or removes broken links
func (cb *ContentBuilder) fixBrokenLink(content string, broken models.BrokenLink) string {
if broken.Action == "replace" && broken.Replacement != "" {
// Replace URL in all link tags
content = strings.ReplaceAll(content, broken.URL, broken.Replacement)
return content
}
if broken.Action == "remove" {
// Remove the entire <a> tag containing this URL
// Pattern: <a [^>]*href="URL"[^>]*>.*?</a>
pattern := fmt.Sprintf(`<a[^>]*href="%s"[^>]*>.*?</a>`, regexp.QuoteMeta(broken.URL))
re := regexp.MustCompile(pattern)
content = re.ReplaceAllString(content, "")
// Also try with escaped quotes
pattern2 := fmt.Sprintf(`<a[^>]*href='%s'[^>]*>.*?</a>`, regexp.QuoteMeta(broken.URL))
re2 := regexp.MustCompile(pattern2)
content = re2.ReplaceAllString(content, "")
}
return content
}
// insertInternalLink inserts an internal link at the appropriate position
func (cb *ContentBuilder) insertInternalLink(content string, link models.InternalLink) string {
// Build the link HTML
linkHTML := fmt.Sprintf(`<a href="/%s/">%s</a>`, link.TargetSlug, link.AnchorText)
// Try to find insertion point based on context
// This is a simplified implementation - could be enhanced with more sophisticated context matching
// If insertion context mentions specific text, try to find and insert after it
if strings.Contains(content, link.InsertionContext) {
// Insert after the context
parts := strings.SplitN(content, link.InsertionContext, 2)
if len(parts) == 2 {
// 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:]
}
}
}
// 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)
}
cb.logger.Warn("Could not find appropriate insertion point for internal link",
"anchor", link.AnchorText,
"context", truncate(link.InsertionContext, 100),
)
return content
}
// isInsideTag checks if text is already inside an HTML tag
func (cb *ContentBuilder) isInsideTag(content, text string) bool {
idx := strings.Index(content, text)
if idx == -1 {
return false
}
// Check if there's an unclosed < before this position
before := content[:idx]
lastOpen := strings.LastIndex(before, "<")
lastClose := strings.LastIndex(before, ">")
// If last < comes after last >, we're inside a tag
return lastOpen > lastClose
}
// findSentenceEnd finds the end of the current sentence (. ! ?)
func findSentenceEnd(text string) int {
for i, char := range text {
if char == '.' || char == '!' || char == '?' {
return i + 1
}
}
return 0
}
// buildFAQBlock generates a RankMath FAQ Gutenberg block
func (cb *ContentBuilder) buildFAQBlock(faq *models.FAQBlock, headingLevel int) string {
if len(faq.Questions) == 0 {
return ""
}
// Build questions JSON array for Gutenberg comment
questionsJSON, err := json.Marshal(faq.Questions)
if err != nil {
cb.logger.Error("Failed to marshal FAQ questions", "error", err)
return ""
}
// Build the HTML structure for each FAQ item
var faqItems strings.Builder
for _, q := range faq.Questions {
faqItems.WriteString(fmt.Sprintf(`<div class="rank-math-faq-item"><h3 class="rank-math-question">%s</h3><div class="rank-math-answer">%s</div></div>`,
q.Title,
q.Content,
))
}
// Gutenberg block format for RankMath FAQ
// <!-- wp:rank-math/faq-block {"questions":[...]} -->
// <div class="wp-block-rank-math-faq-block">
// <div class="rank-math-faq-item">...</div>
// ...
// </div>
// <!-- /wp:rank-math/faq-block -->
if headingLevel < 1 || headingLevel > 6 {
headingLevel = 2
}
heading := fmt.Sprintf(`<!-- wp:heading {"level":%d} -->
<h%d>Frequently Asked Questions</h%d>
<!-- /wp:heading -->`, headingLevel, headingLevel, headingLevel)
return fmt.Sprintf(`%s
<!-- wp:rank-math/faq-block {"questions":%s} -->
<div class="wp-block-rank-math-faq-block">%s</div>
<!-- /wp:rank-math/faq-block -->`, heading, string(questionsJSON), faqItems.String())
}
func (cb *ContentBuilder) hasFAQBlock(content string) bool {
if strings.Contains(content, "wp:rank-math/faq-block") {
return true
}
if strings.Contains(content, "wp-block-rank-math-faq-block") {
return true
}
return false
}
func (cb *ContentBuilder) hasGutenbergBlocks(content string) bool {
return strings.Contains(content, "<!-- wp:")
}
func (cb *ContentBuilder) wrapInGutenbergBlocks(content string) string {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return content
}
blockRe := regexp.MustCompile(`(?is)(<h[1-6][^>]*>.*?</h[1-6]>|<ul[^>]*>.*?</ul>|<ol[^>]*>.*?</ol>|<p[^>]*>.*?</p>)`)
matches := blockRe.FindAllStringIndex(trimmed, -1)
if len(matches) == 0 {
parts := splitParagraphs(trimmed)
var builder strings.Builder
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if i > 0 && builder.Len() > 0 {
builder.WriteString("\n\n")
}
builder.WriteString(wrapTextAsParagraph(part))
}
return builder.String()
}
var builder strings.Builder
last := 0
for i, match := range matches {
start, end := match[0], match[1]
if start > last {
leading := strings.TrimSpace(trimmed[last:start])
if leading != "" {
if builder.Len() > 0 {
builder.WriteString("\n\n")
}
builder.WriteString(wrapTextAsParagraph(leading))
}
}
block := trimmed[start:end]
if builder.Len() > 0 {
builder.WriteString("\n\n")
}
builder.WriteString(wrapHTMLBlock(block))
last = end
if i == len(matches)-1 && last < len(trimmed) {
trailing := strings.TrimSpace(trimmed[last:])
if trailing != "" {
builder.WriteString("\n\n")
builder.WriteString(wrapTextAsParagraph(trailing))
}
}
}
return builder.String()
}
func wrapTextAsParagraph(text string) string {
return fmt.Sprintf("<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", text)
}
func wrapHTMLBlock(block string) string {
block = strings.TrimSpace(block)
if strings.HasPrefix(strings.ToLower(block), "<p") {
return fmt.Sprintf("<!-- wp:paragraph -->\n%s\n<!-- /wp:paragraph -->", block)
}
if strings.HasPrefix(strings.ToLower(block), "<ul") || strings.HasPrefix(strings.ToLower(block), "<ol") {
return fmt.Sprintf("<!-- wp:list -->\n%s\n<!-- /wp:list -->", block)
}
if strings.HasPrefix(strings.ToLower(block), "<h") {
level := extractHeadingLevel(block)
if level > 0 {
return fmt.Sprintf("<!-- wp:heading {\"level\":%d} -->\n%s\n<!-- /wp:heading -->", level, block)
}
return fmt.Sprintf("<!-- wp:heading -->\n%s\n<!-- /wp:heading -->", block)
}
return fmt.Sprintf("<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", block)
}
func extractHeadingLevel(block string) int {
re := regexp.MustCompile(`(?i)<h([1-6])[^>]*>`)
matches := re.FindStringSubmatch(block)
if len(matches) != 2 {
return 0
}
switch matches[1] {
case "1":
return 1
case "2":
return 2
case "3":
return 3
case "4":
return 4
case "5":
return 5
case "6":
return 6
default:
return 0
}
}
func detectHeadingLevel(content string) int {
blockHeadingRe := regexp.MustCompile(`<!--\s*wp:heading\s*(\{[^}]*\})?\s*-->`)
blockHeadingLevels := regexp.MustCompile(`"level"\s*:\s*(\d)`)
if match := blockHeadingRe.FindString(content); match != "" {
levelMatch := blockHeadingLevels.FindStringSubmatch(match)
if len(levelMatch) == 2 {
if level, err := strconv.Atoi(levelMatch[1]); err == nil {
return level
}
}
}
htmlHeadingRe := regexp.MustCompile(`(?i)<h([1-6])[^>]*>`)
if match := htmlHeadingRe.FindStringSubmatch(content); len(match) == 2 {
if level, err := strconv.Atoi(match[1]); err == nil {
return level
}
}
return 2
}
func splitParagraphs(content string) []string {
re := regexp.MustCompile(`\r?\n\s*\r?\n`)
return re.Split(content, -1)
}
// normalizeText normalizes text for comparison (handles whitespace, HTML entities)
func normalizeText(text string) string {
// Normalize whitespace
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
text = strings.TrimSpace(text)
// Decode common HTML entities
text = strings.ReplaceAll(text, "&nbsp;", " ")
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
return text
}
// truncate truncates a string to max length
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}