459 lines
13 KiB
Go
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, " ", " ")
|
|
text = strings.ReplaceAll(text, "&", "&")
|
|
text = strings.ReplaceAll(text, "<", "<")
|
|
text = strings.ReplaceAll(text, ">", ">")
|
|
text = strings.ReplaceAll(text, """, "\"")
|
|
text = strings.ReplaceAll(text, "'", "'")
|
|
|
|
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] + "..."
|
|
}
|