Files
2026-01-27 22:28:35 +01:00

694 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package seo
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"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)
unplacedLinks := make([]models.InternalLink, 0)
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,
)
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
if !originalHasBlocks {
content = cb.wrapInGutenbergBlocks(content)
}
// 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)
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
}
// 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 quotes and whitespace)
if start, end, ok := findNormalizedSpan(content, change.Original); ok {
return content[:start] + change.Updated + content[end:]
}
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)
}
if start, end, ok := findNormalizedSpan(content, factCheck.Claim); ok {
return content[:start] + factCheck.UpdatedText + content[end:]
}
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, bool) {
// 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:], true
}
}
}
if _, end, ok := findNormalizedSpan(content, link.InsertionContext); ok {
remainder := content[end:]
endIdx := findSentenceEnd(remainder)
if endIdx > 0 {
return content[:end] + remainder[:endIdx] + " " + linkHTML + remainder[endIdx:], true
}
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), true
}
cb.logger.Warn("Could not find appropriate insertion point for internal link",
"anchor", link.AnchorText,
"context", truncate(link.InsertionContext, 100),
)
return content, false
}
// 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
}
func normalizeForMatch(input string) string {
normalized, _ := normalizeWithMap(input)
return normalized
}
func findNormalizedSpan(content, needle string) (int, int, bool) {
if strings.TrimSpace(needle) == "" {
return 0, 0, false
}
normalizedContent, indexMap := normalizeWithMap(content)
normalizedNeedle := normalizeForMatch(needle)
if normalizedNeedle == "" {
return 0, 0, false
}
idx := strings.Index(normalizedContent, normalizedNeedle)
if idx == -1 {
return 0, 0, false
}
endIdx := idx + len(normalizedNeedle) - 1
if idx < 0 || endIdx >= len(indexMap) {
return 0, 0, false
}
start := indexMap[idx]
end := indexMap[endIdx] + 1
if start < 0 || end > len(content) || start >= end {
return 0, 0, false
}
return start, end, true
}
func normalizeWithMap(input string) (string, []int) {
var b strings.Builder
indexMap := make([]int, 0, len(input))
lastSpace := false
for i, r := range input {
if unicode.IsSpace(r) {
if lastSpace {
continue
}
lastSpace = true
b.WriteByte(' ')
indexMap = append(indexMap, i)
continue
}
lastSpace = false
r = normalizeQuote(r)
token := strings.ToLower(string(r))
b.WriteString(token)
for j := 0; j < len(token); j++ {
indexMap = append(indexMap, i)
}
}
return b.String(), indexMap
}
func normalizeQuote(r rune) rune {
switch r {
case '“', '”':
return '"'
case '', '':
return '\''
default:
return r
}
}
func insertAfterFirstParagraph(content, linkHTML string) (string, bool) {
if strings.TrimSpace(content) == "" {
return content, false
}
if idx := strings.Index(content, "</p>"); idx != -1 {
insertAt := idx + len("</p>")
return content[:insertAt] + " " + linkHTML + content[insertAt:], true
}
if idx := strings.Index(content, "\n\n"); idx != -1 {
return content[:idx] + "\n\n" + linkHTML + "\n\n" + content[idx+2:], true
}
return content + "\n\n" + linkHTML, true
}
// 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>|<figure[^>]*wp-block-table[^>]*>.*?</figure>|<table[^>]*>.*?</table>)`)
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)
}
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)
}
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)
}
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
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] + "..."
}