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 tag containing this URL // Pattern: ]*href="URL"[^>]*>.*? pattern := fmt.Sprintf(`]*href="%s"[^>]*>.*?`, regexp.QuoteMeta(broken.URL)) re := regexp.MustCompile(pattern) content = re.ReplaceAllString(content, "") // Also try with escaped quotes pattern2 := fmt.Sprintf(`]*href='%s'[^>]*>.*?`, 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(`%s`, 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(`
%s
\n", text) } func wrapHTMLBlock(block string) string { block = strings.TrimSpace(block) if strings.HasPrefix(strings.ToLower(block), "\n%s\n", block) } if strings.HasPrefix(strings.ToLower(block), "
%s
\n", block) } func extractHeadingLevel(block string) int { re := regexp.MustCompile(`(?i)