get_api_key(); if (empty($api_key)) { return new WP_Error('no_api_key', __('OpenAI API key not configured. Please add it in Settings.', 'aeo-bob-lite')); } $response = wp_remote_post('https://api.openai.com/v1/chat/completions', array( 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => json_encode(array( 'model' => 'gpt-4o-mini', 'messages' => array( array('role' => 'system', 'content' => 'You are an SEO and AEO expert helping optimize content for both search engines and AI systems like ChatGPT, Claude, and Perplexity.'), array('role' => 'user', 'content' => $prompt) ), 'max_tokens' => $max_tokens, 'temperature' => 0.7, )), 'timeout' => 30, )); if (is_wp_error($response)) { return $response; } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); // Validate JSON parsing succeeded if (json_last_error() !== JSON_ERROR_NONE) { return new WP_Error('json_error', __('Invalid JSON response from API', 'aeo-bob-lite')); } if ($status_code !== 200) { $error_msg = isset($data['error']['message']) ? $data['error']['message'] : __('API request failed', 'aeo-bob-lite'); return new WP_Error('api_error', $error_msg); } if (!isset($data['choices'][0]['message']['content'])) { return new WP_Error('invalid_response', __('Invalid API response', 'aeo-bob-lite')); } return $data['choices'][0]['message']['content']; } /** * Generate optimization suggestions (3 content + 3 FAQ) */ public function generate_suggestions($post_id) { $post = get_post($post_id); if (!$post) { return false; } $api_key = $this->get_api_key(); if (empty($api_key)) { return false; } // Get author voice profile if available $author_profile = get_post_meta($post_id, '_aeo_bob_author_voice_profile', true); // Prepare content for analysis - check if Elementor post $is_elementor = $this->is_elementor_post($post_id); if ($is_elementor) { // Extract text from Elementor data for analysis $content = $this->extract_text_from_elementor($post_id); if (empty($content)) { // Fallback to post_content if Elementor extraction fails $content = $post->post_content; } } else { $content = $post->post_content; } $title = $post->post_title; // Count words and existing headers for AI context $content_plain = wp_strip_all_tags($content); $word_count = str_word_count($content_plain); $existing_headers = preg_match_all('/]*>/i', $content); // Extract existing FAQs to avoid duplicates (IMPROVED: more comprehensive extraction) $existing_faqs = array(); // Pattern 1: FAQ section headers - extract questions from various tags if (preg_match_all('/]*>(.*?faq.*?)<\/h[23]>/is', $content)) { // Extract questions from headings, strong, list items, paragraphs if (preg_match_all('/<(?:h[3-6]|strong|b|li|p)[^>]*>([^<]*\?[^<]*)<\/(?:h[3-6]|strong|b|li|p)>/i', $content, $questions)) { $existing_faqs = array_merge($existing_faqs, $questions[1]); } } // Pattern 2: Q: or Question: prefix patterns (common in plain text FAQs) if (preg_match_all('/(?:^|\n)\s*(?:Q:|Question:)\s*([^\n]+\?)/im', wp_strip_all_tags($content), $q_matches)) { $existing_faqs = array_merge($existing_faqs, $q_matches[1]); } // Normalize and deduplicate $existing_faqs = array_unique(array_map('trim', $existing_faqs)); $existing_faqs = array_slice($existing_faqs, 0, 15); // Limit to 15 // Build prompt for OpenAI - Request REWRITES with source text + new content $prompt = "You are \"AEO Bob Lite\", an Answer Engine Optimization assistant.\n\n"; $prompt .= "Your job is to improve this article for:\n"; $prompt .= "- Human readers, and\n"; $prompt .= "- Answer engines / AI overviews\n\n"; $prompt .= "CONTENT ANALYSIS:\n"; $prompt .= "- Word count: " . $word_count . " words\n"; $prompt .= "- Existing headers: " . $existing_headers . "\n"; $prompt .= "- Needs structure: " . ($word_count > 1500 && $existing_headers < 3 ? "YES - HEADERS REQUIRED" : "No") . "\n\n"; $prompt .= "CORE PRINCIPLES:\n"; $prompt .= "1. REWRITES over additions - Identify weak/unclear paragraphs and improve them\n"; $prompt .= "2. NEW CONTENT when existing content is already good but missing topics\n"; $prompt .= "3. NO REDUNDANCY - Don't restate what already exists\n"; $prompt .= "4. MATCH THE AUTHOR'S VOICE exactly\n\n"; if ($author_profile) { $prompt .= "AUTHOR'S VOICE (MUST MATCH EXACTLY):\n"; $prompt .= "- Style: " . ($author_profile['style'] ?? 'professional') . "\n"; $prompt .= "- Tone: " . ($author_profile['tone'] ?? 'informative') . "\n"; $prompt .= "- Reading Level: " . ($author_profile['reading_level'] ?? 'intermediate') . "\n\n"; } $prompt .= "WHAT TO PRODUCE:\n\n"; $prompt .= "Return EXACTLY this JSON structure:\n"; $prompt .= "{\n"; $prompt .= " \"headers\": [ // 0-3 headers (ONLY if content needs structure)\n"; $prompt .= " {\n"; $prompt .= " \"text\": \"H2 Header (3-8 words, clear section heading)\",\n"; $prompt .= " \"placement\": \"Brief description of where this header will go (e.g., 'above the section about event details')\"\n"; $prompt .= " }\n"; $prompt .= " ],\n"; $prompt .= " \"content_suggestions\": [\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"Original paragraph text to rewrite (or empty string if this is new content)\",\n"; $prompt .= " \"rewrite\": \"Improved version (max 120 words)\",\n"; $prompt .= " \"type\": \"rewrite\" or \"addition\"\n"; $prompt .= " },\n"; $prompt .= " { /* 2 more suggestions */ }\n"; $prompt .= " ],\n"; $prompt .= " \"faqs\": [\n"; $prompt .= " {\"q\": \"Question 1?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 2?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 3?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 4?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 5?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 6?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"}\n"; $prompt .= " ]\n"; $prompt .= "}\n\n"; $prompt .= "DETAILED GUIDANCE:\n\n"; $prompt .= "1) HEADERS (0-3 H2 suggestions - CRITICAL for long content):\n"; $prompt .= " - REQUIRED if content is >1500 words and has fewer than 3 headers\n"; $prompt .= " - SKIP if content already has 3+ good headers\n"; $prompt .= " - SKIP if content is <800 words\n"; $prompt .= " - SUGGEST when: long content lacks structure, topics aren't clearly separated\n"; $prompt .= " - Each header must have TWO fields:\n"; $prompt .= " * \"text\": Clear, descriptive section heading (3-8 words)\n"; $prompt .= " * \"placement\": Where it will go (e.g., \"above the section about food philosophy\", \"above the event details\")\n"; $prompt .= " - Help structure content for answer engines\n"; $prompt .= " - Natural language (not clickbaity)\n"; $prompt .= " - DO NOT suggest FAQ headers (those are separate)\n"; $prompt .= " - Return empty array [] ONLY if headers aren't needed\n\n"; $prompt .= "2) CONTENT SUGGESTIONS (3 items - mix of rewrites & additions):\n"; $prompt .= " \n"; $prompt .= " **For REWRITES** (improve existing weak paragraphs):\n"; $prompt .= " - \"source\": MUST copy 4-15 words EXACTLY from the article below (NOT placeholder text!)\n"; $prompt .= " - \"rewrite\": Your improved version (max 120 words)\n"; $prompt .= " - \"type\": \"rewrite\"\n"; $prompt .= " - ⚠️ CRITICAL: Read 2-3 sentences AROUND your target to check for redundancy\n"; $prompt .= " - If your rewrite makes the NEXT sentence redundant, INCLUDE IT in your rewrite and consolidate\n"; $prompt .= " - Target paragraphs that are: unclear, too vague, grammatically weak, or missing key info\n"; $prompt .= " - DO NOT rewrite paragraphs that are already clear and well-written\n"; $prompt .= " - ✅ ENSURE all rewrites have perfect grammar and spelling\n\n"; $prompt .= " EXAMPLE REWRITE (simple):\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"Paul Rodney Turner was a celibate monk for 14 years\",\n"; $prompt .= " \"rewrite\": \"Paul Rodney Turner dedicated 14 years of his life as a celibate monk, deeply immersing himself in spiritual practice and vegetarian cuisine.\",\n"; $prompt .= " \"type\": \"rewrite\"\n"; $prompt .= " }\n\n"; $prompt .= " EXAMPLE REWRITE (with consolidation to avoid redundancy):\n"; $prompt .= " ORIGINAL TEXT:\n"; $prompt .= " \"This chapter provides some practical guidance on reconnecting to the natural world. I also aim to heighten the reader's appreciation for Intelligent design.\"\n"; $prompt .= " \n"; $prompt .= " BAD REWRITE (creates redundancy):\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"This chapter provides some practical guidance\",\n"; $prompt .= " \"rewrite\": \"This chapter offers practical advice on reconnecting with the natural world. It aims to enhance appreciation for intelligent design.\",\n"; $prompt .= " \"type\": \"rewrite\"\n"; $prompt .= " }\n"; $prompt .= " ❌ Problem: Next sentence 'I also aim to heighten appreciation...' is now redundant!\n"; $prompt .= " \n"; $prompt .= " GOOD REWRITE (consolidates to avoid redundancy):\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"This chapter provides some practical guidance on reconnecting to the natural world. I also aim to heighten\",\n"; $prompt .= " \"rewrite\": \"This chapter offers practical advice on reconnecting with the natural world while enhancing your appreciation for intelligent design through the sacred geometry found in whole foods.\",\n"; $prompt .= " \"type\": \"rewrite\"\n"; $prompt .= " }\n"; $prompt .= " ✅ Solution: Included BOTH sentences in source, consolidated into ONE improved sentence!\n\n"; $prompt .= " **For NEW CONTENT** (when existing content is good but incomplete):\n"; $prompt .= " - \"source\": \"\" (empty string)\n"; $prompt .= " - \"rewrite\": New paragraph to add (max 120 words)\n"; $prompt .= " - \"type\": \"addition\"\n"; $prompt .= " - Add NEW value: practical example, missing definition, simple practice, modern application\n"; $prompt .= " - DO NOT duplicate existing concepts\n"; $prompt .= " - ✅ ENSURE all new content has perfect grammar and spelling\n\n"; $prompt .= " EXAMPLE ADDITION:\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"\",\n"; $prompt .= " \"rewrite\": \"Food Yoga workshops typically begin with a meditation to set intention, followed by hands-on preparation of plant-based dishes using mindful techniques.\",\n"; $prompt .= " \"type\": \"addition\"\n"; $prompt .= " }\n\n"; $prompt .= " **Strategy**: Prioritize rewrites first. Consolidate adjacent sentences if your rewrite makes them redundant. Only suggest additions if all existing content is already good.\n\n"; $prompt .= "3) FAQS (6 Q&A pairs - we'll filter to best 3):\n"; $prompt .= " - Questions real users might ask about this topic\n"; $prompt .= " - Start answer with 1-2 sentence direct response\n"; $prompt .= " - Add 1-3 clarifying sentences (max 90 words total)\n"; $prompt .= " - Generate 6 diverse questions (more than needed for better variety)\n"; if (!empty($existing_faqs)) { $prompt .= " \n"; $prompt .= " ⚠️ EXISTING FAQ QUESTIONS (DO NOT DUPLICATE OR PARAPHRASE):\n"; foreach ($existing_faqs as $existing_q) { $prompt .= " - " . trim($existing_q) . "\n"; } $prompt .= " \n"; $prompt .= " Your questions MUST be meaningfully different from the above.\n"; $prompt .= " Do NOT rephrase these questions.\n"; $prompt .= " Focus on NEW angles, topics, or user needs not covered above.\n"; } $prompt .= "\n"; $prompt .= "CRITICAL RULES:\n"; $prompt .= "- Respond ONLY with valid JSON (no explanation outside JSON)\n"; $prompt .= "- For rewrites: \"source\" MUST be ACTUAL TEXT copied from the article below (4-15 words minimum)\n"; $prompt .= "- ❌ NEVER use placeholder text like \"original paragraph text to rewrite\"\n"; $prompt .= "- ✅ ALWAYS copy exact words from the article\n"; $prompt .= "- ⚠️ READ surrounding sentences - if your rewrite makes adjacent text redundant, CONSOLIDATE them\n"; $prompt .= "- For consolidation: Include MORE of the source text (2+ sentences) and merge into one improved paragraph\n"; $prompt .= "- For additions: Leave source empty (\"\") and set type to \"addition\"\n"; $prompt .= "- Match author's voice exactly\n"; $prompt .= "- No redundancy with existing content\n"; $prompt .= "- No duplicate FAQ questions\n\n"; $prompt .= "Current Title:\n"; $prompt .= $title . "\n\n"; $prompt .= "Content to analyze:\n"; $prompt .= wp_strip_all_tags($content) . "\n"; // Call OpenAI API $response = wp_remote_post('https://api.openai.com/v1/chat/completions', array( 'timeout' => 60, 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => json_encode(array( 'model' => 'gpt-4o-mini', 'messages' => array( array('role' => 'system', 'content' => 'You are AEO Bob Lite. CRITICAL: For rewrites, read surrounding context (2-3 sentences). If your rewrite makes adjacent sentences redundant, CONSOLIDATE them by including MORE source text (2+ sentences) and merging into one improved paragraph. The "source" field MUST contain ACTUAL TEXT (4-15 words minimum) copied EXACTLY from the user\'s article. NEVER use placeholder text. For additions, use empty source "". Respond ONLY with valid JSON. Format: {source: "actual text from article", rewrite: "improved", type: "rewrite" or "addition"}. Headers: 0-3 suggestions (ONLY if content needs structure), 3-8 words each, avoid FAQ headers, return [] if not needed. Content: max 120 words. FAQs: Generate 6 diverse questions (we filter to best 3), max 90 words each, must be MEANINGFULLY DIFFERENT from existing questions (not just rephrased). ✅ All content MUST have perfect grammar and spelling.'), array('role' => 'user', 'content' => $prompt) ), 'temperature' => 0.7, 'max_tokens' => 1500 )) )); if (is_wp_error($response)) { return false; } $body = json_decode(wp_remote_retrieve_body($response), true); // Validate JSON parsing succeeded if (json_last_error() !== JSON_ERROR_NONE) { return false; } if (!isset($body['choices'][0]['message']['content'])) { return false; } $content_response = $body['choices'][0]['message']['content']; // Extract JSON from response (handle markdown code blocks) if (preg_match('/```json\s*(.*?)\s*```/s', $content_response, $matches)) { $content_response = $matches[1]; } elseif (preg_match('/```\s*(.*?)\s*```/s', $content_response, $matches)) { $content_response = $matches[1]; } $suggestions = json_decode($content_response, true); // Validate JSON parsing succeeded if (json_last_error() !== JSON_ERROR_NONE || !$suggestions) { return false; } // Normalize headers (handle both old string format and new object format) if (isset($suggestions['headers']) && is_array($suggestions['headers'])) { $normalized_headers = array(); foreach ($suggestions['headers'] as $header) { if (is_string($header)) { // Old format: just a string $normalized_headers[] = array( 'text' => $header, 'placement' => 'throughout the content' ); } elseif (is_array($header) && isset($header['text'])) { // New format: object with text + placement $normalized_headers[] = array( 'text' => $header['text'], 'placement' => isset($header['placement']) ? $header['placement'] : 'throughout the content' ); } } $suggestions['headers'] = $normalized_headers; } // Normalize content_suggestions key (handle both old and new format) if (isset($suggestions['content_suggestions'])) { $suggestions['content'] = $suggestions['content_suggestions']; } elseif (!isset($suggestions['content'])) { return false; } // Check for FAQs (handle both "faqs" and potential "faq" key) if (!isset($suggestions['faqs'])) { return false; } // Normalize FAQ structure (handle both "q"/"a" and "question"/"answer" formats) foreach ($suggestions['faqs'] as $index => $faq) { if (isset($faq['q']) && isset($faq['a'])) { $suggestions['faqs'][$index] = array( 'question' => $faq['q'], 'answer' => $faq['a'] ); } } // Ensure limits and deduplicate FAQs $suggestions['titles'] = isset($suggestions['titles']) ? array_slice($suggestions['titles'], 0, 3) : array(); $suggestions['content'] = array_slice($suggestions['content'], 0, 3); // Deduplicate FAQs against existing content (filter 6 down to best 3) $suggestions['faqs'] = $this->deduplicate_faqs($suggestions['faqs'], $existing_faqs); // Add original title as first option if (!empty($suggestions['titles'])) { array_unshift($suggestions['titles'], $title); } return $suggestions; } /** * Deduplicate FAQ suggestions against existing FAQs * Uses normalized string comparison with similarity threshold */ private function deduplicate_faqs($suggested_faqs, $existing_faqs) { if (empty($existing_faqs)) { return array_slice($suggested_faqs, 0, 3); // No existing, return first 3 } // Normalize existing questions $normalized_existing = array(); foreach ($existing_faqs as $q) { $normalized_existing[] = $this->normalize_faq_question($q); } // Filter suggested FAQs $filtered = array(); foreach ($suggested_faqs as $faq) { $question = isset($faq['question']) ? $faq['question'] : (isset($faq['q']) ? $faq['q'] : ''); if (empty($question)) continue; $normalized_q = $this->normalize_faq_question($question); // Check similarity against existing $is_duplicate = false; foreach ($normalized_existing as $existing_normalized) { if ($this->faq_similarity($normalized_q, $existing_normalized) > 0.75) { $is_duplicate = true; break; } } if (!$is_duplicate) { $filtered[] = $faq; } } return array_slice($filtered, 0, 3); // Return best 3 } /** * Normalize FAQ question for comparison */ private function normalize_faq_question($question) { $q = strtolower(trim($question)); // Remove question marks $q = str_replace('?', '', $q); // Remove punctuation $q = preg_replace('/[^\w\s]/', '', $q); // Collapse whitespace $q = preg_replace('/\s+/', ' ', $q); return trim($q); } /** * Simple similarity score (0-1) using Levenshtein distance */ private function faq_similarity($str1, $str2) { $max_len = max(strlen($str1), strlen($str2)); if ($max_len === 0) return 1.0; $distance = levenshtein(substr($str1, 0, 255), substr($str2, 0, 255)); // Levenshtein limit return 1 - ($distance / $max_len); } /** * Extract existing FAQ questions from content * Detects questions under FAQ headings and Q:/Question: patterns * * @param string $content Post content HTML * @return array Normalized FAQ questions */ private function extract_existing_faq_questions_from_content($content) { $existing_questions = array(); // Pattern 1: Under "Frequently Asked Questions" / "FAQ" / "FAQs" headings if (preg_match('/]*>\s*(?:frequently asked questions|faqs?)\s*<\/h[23]>/i', $content, $faq_header_match, PREG_OFFSET_CAPTURE)) { $faq_section_start = $faq_header_match[0][1]; $faq_section = substr($content, $faq_section_start); // Extract questions from H3/H4 tags or strong tags containing "Q:" if (preg_match_all('/]*>(?:\s*)?\s*Q:\s*([^<]+?)(?:<\/strong>)?\s*<\/h[34]>/i', $faq_section, $h_matches)) { $existing_questions = array_merge($existing_questions, $h_matches[1]); } // Extract from Q: ... patterns if (preg_match_all('/\s*Q:\s*([^<]+?)<\/strong>/i', $faq_section, $strong_matches)) { $existing_questions = array_merge($existing_questions, $strong_matches[1]); } // Extract from headings ending with ? (common FAQ pattern) if (preg_match_all('/]*>([^<]+\?)\s*<\/h[3-6]>/i', $faq_section, $heading_q_matches)) { $existing_questions = array_merge($existing_questions, $heading_q_matches[1]); } } // Pattern 2: Q: or Question: prefix patterns anywhere in content if (preg_match_all('/(?:^|\n|

|

  • )\s*(?:Q:|Question:)\s*([^\n<]+\?)/im', $content, $q_matches)) { $existing_questions = array_merge($existing_questions, $q_matches[1]); } // Normalize all questions for comparison $normalized = array(); foreach ($existing_questions as $q) { $normalized[] = $this->normalize_faq_question($q); } return array_unique($normalized); } /** * Apply selected suggestions to post content */ public function apply_suggestions($post_id, $selected, $suggestions) { $post = get_post($post_id); if (!$post) { return false; } // ✅ LITE v1.5.36: Elementor one-click apply uses Elementor Document API // Elementor posts now update the actual document structure, not just post_content if ($this->is_elementor_post($post_id)) { // Use Elementor-specific apply method that updates the document structure return $this->apply_suggestions_to_elementor($post_id, $selected, $suggestions); } $content = $post->post_content; $title = $post->post_title; $title_changed = false; // Separate title, header, content and FAQ selections $selected_title = null; $header_suggestions = array(); $content_suggestions = array(); $faq_suggestions = array(); foreach ($selected as $item) { $type = $item['type']; $index = intval($item['index']); if ($type === 'title' && isset($suggestions['titles'][$index])) { $selected_title = $suggestions['titles'][$index]; } elseif ($type === 'header' && isset($suggestions['headers'][$index])) { $header_suggestions[] = $suggestions['headers'][$index]; } elseif ($type === 'content' && isset($suggestions['content'][$index])) { // Handle both old string format and new object format $content_item = $suggestions['content'][$index]; if (is_array($content_item)) { // New format: {source, rewrite, type} $content_suggestions[] = $content_item; $preview = is_string($content_item['rewrite']) ? substr($content_item['rewrite'], 0, 100) : '[object]'; } else { // Old format: just a string - convert to new format $content_suggestions[] = array( 'source' => '', 'rewrite' => $content_item, 'type' => 'addition' ); } } elseif ($type === 'faq' && isset($suggestions['faqs'][$index])) { $faq_suggestions[] = $suggestions['faqs'][$index]; } } // Find if FAQ section already exists $faq_position = false; if (preg_match('/]*>\s*frequently asked questions\s*<\/h2>/i', $content, $matches, PREG_OFFSET_CAPTURE)) { $faq_position = $matches[0][1]; } // Add content suggestions FIRST - handle both rewrites and additions if (!empty($content_suggestions)) { foreach ($content_suggestions as $index => $item) { $suggestion_type = isset($item['type']) ? $item['type'] : 'addition'; $source_text = isset($item['source']) ? trim($item['source']) : ''; $rewrite_text = isset($item['rewrite']) ? $item['rewrite'] : (is_string($item) ? $item : ''); if (!empty($source_text)) { } if (empty($rewrite_text)) { continue; } // Clean the rewrite text - strip any malformed HTML tags/entities // First decode any HTML entities, then re-encode properly $rewrite_text = html_entity_decode($rewrite_text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Remove any malformed HTML comment tags or similar artifacts $rewrite_text = preg_replace('/]*>/', '', $rewrite_text); // STRIP UNDERLINE TAGS - Remove tags and underline styles $rewrite_text = preg_replace('/(.*?)<\/u>/i', '$1', $rewrite_text); $rewrite_text = preg_replace('/]*>(.*?)<\/u>/i', '$1', $rewrite_text); $rewrite_text = preg_replace('/style\s*=\s*["\'][^"\']*text-decoration:\s*underline[^"\']*["\']/i', '', $rewrite_text); // Now apply WordPress sanitization $clean_rewrite = wp_kses_post($rewrite_text); if ($suggestion_type === 'rewrite' && !empty($source_text)) { // REWRITE: Smart sentence-level replacement // Strategy: Use simple string replacement in the HTML // Works regardless of whether content is wrapped in

    tags or not // Strip HTML from source for matching $source_plain = wp_strip_all_tags($source_text); $source_plain_clean = trim($source_plain); // Try to find the source text in the content $source_pos = stripos($content, $source_plain_clean); if ($source_pos !== false) { // Find the sentence boundaries around the source text // SAFETY: Limit search to +/- 300 chars from source position to prevent over-expansion $search_radius = 300; // Look backwards for sentence start (., !, ?, or paragraph start) $sentence_start = max(0, $source_pos - $search_radius); $found_start = false; for ($i = $source_pos - 1; $i >= $sentence_start; $i--) { $char = substr($content, $i, 1); // Check for sentence terminators if (in_array($char, ['.', '!', '?'])) { // Found terminator, skip any whitespace after it $sentence_start = $i + 1; while ($sentence_start < $source_pos && ctype_space(substr($content, $sentence_start, 1))) { $sentence_start++; } $found_start = true; break; } // Check for HTML tags that indicate paragraph/block boundaries if ($i > 4 && (substr($content, $i-4, 4) === '

    ' || substr($content, $i-5, 5) === '
  • ' || substr($content, $i-5, 5) === '' || substr($content, $i-5, 5) === '')) { $sentence_start = $i; $found_start = true; break; } } // If no sentence start found within radius, just replace the source text itself if (!$found_start) { $sentence_start = $source_pos; } // Look forward for sentence end $source_end = $source_pos + strlen($source_plain_clean); $sentence_end = min(strlen($content), $source_end + $search_radius); $found_end = false; for ($i = $source_end; $i < $sentence_end; $i++) { $char = substr($content, $i, 1); if (in_array($char, ['.', '!', '?'])) { $sentence_end = $i + 1; $found_end = true; break; } // Check for block-ending tags if ($i < strlen($content) - 4 && (substr($content, $i, 4) === '

    ' || substr($content, $i, 4) === '

  • ' || substr($content, $i, 4) === '

    ' || substr($content, $i, 4) === '

    ')) { $sentence_end = $i; $found_end = true; break; } } // If no sentence end found within radius, just go to end of source text if (!$found_end) { $sentence_end = $source_end; } // Extract the text to replace $sentence_to_replace = substr($content, $sentence_start, $sentence_end - $sentence_start); $sentence_to_replace_trimmed = trim($sentence_to_replace); // STRICT SAFETY CHECK: Don't replace if too long (likely over-expansion) // Max 200 chars = about 2-3 sentences max if (strlen($sentence_to_replace_trimmed) > 200) { // Fall back to just replacing the source text itself $sentence_to_replace_trimmed = $source_plain_clean; } // Wrap rewrite appropriately $clean_rewrite_final = $clean_rewrite; // Don't add

    tags if we're replacing inside existing structure if (stripos($sentence_to_replace, '

    ') === false && stripos($sentence_to_replace, '

  • ') === false && stripos($clean_rewrite, '

    ') === false) { $clean_rewrite_final = '

    ' . $clean_rewrite . '

    '; } // Do the replacement - find first occurrence and replace it $content_before_length = strlen($content); $content = preg_replace( '/' . preg_quote($sentence_to_replace_trimmed, '/') . '/i', $clean_rewrite_final, $content, 1 // Only replace first occurrence ); $content_after_length = strlen($content); if ($content_after_length !== $content_before_length) { } else { $suggestion_type = 'addition'; } } else { $suggestion_type = 'addition'; } } if ($suggestion_type === 'addition') { // ADDITION: Insert before FAQ section if it exists, otherwise append // Wrap in

    tags if not already wrapped if (stripos($clean_rewrite, '

    ') === false) { $clean_rewrite = "\n\n

    " . $clean_rewrite . "

    "; } else { $clean_rewrite = "\n\n" . $clean_rewrite; } if ($faq_position !== false) { // Re-find FAQ position in case content changed if (preg_match('/]*>\s*frequently asked questions\s*<\/h2>/i', $content, $matches, PREG_OFFSET_CAPTURE)) { $current_faq_pos = $matches[0][1]; $content = substr($content, 0, $current_faq_pos) . $clean_rewrite . "\n\n" . substr($content, $current_faq_pos); } else { $content .= $clean_rewrite; } } else { $content .= $clean_rewrite; } } } } // Smart header placement - distribute throughout content, avoid FAQ sections // IMPORTANT: Do this AFTER content suggestions so there's more content to distribute across if (!empty($header_suggestions)) { // Find FAQ section position to avoid placing headers there $faq_start_pos = false; if (preg_match('/]*>.*?faq.*?<\/h[23]>/is', $content, $faq_match, PREG_OFFSET_CAPTURE)) { $faq_start_pos = $faq_match[0][1]; } // Split content into paragraphs $paragraphs = preg_split('/(<\/p>)/i', $content, -1, PREG_SPLIT_DELIM_CAPTURE); $total_paragraphs = count($paragraphs); // Safety check: Need at least 2 paragraphs to insert headers if ($total_paragraphs < 4) { // 4 = at least 2 paragraphs (paragraph +

    closing tag each) // Fallback: Insert all headers at the beginning $header_html = ''; foreach ($header_suggestions as $i => $header) { // Handle both old format (string) and new format (object with text + placement) $header_text = is_string($header) ? $header : (isset($header['text']) ? $header['text'] : ''); if (empty($header_text)) continue; $clean_header = wp_kses_post($header_text); if (stripos($header_text, '

    ') === false && stripos($header_text, '

    ') === false) { $header_html .= "

    " . $clean_header . "

    \n\n"; } else { $header_html .= $clean_header . "\n\n"; } } $content = $header_html . $content; } else { // Calculate insertion points (distribute headers evenly) $header_count = count($header_suggestions); $interval = max(2, floor($total_paragraphs / ($header_count + 1)) * 2); // Multiply by 2 because we have paragraph + closing tag // Reconstruct content to calculate byte positions $running_position = 0; $paragraph_positions = array(); foreach ($paragraphs as $i => $para) { $paragraph_positions[$i] = $running_position; $running_position += strlen($para); } // Insert headers at calculated intervals $inserted = 0; $skipped_count = 0; for ($i = 0; $i < $header_count && $inserted < $header_count; $i++) { $insert_position = ($i + 1) * $interval; // Make sure we don't go past the end if ($insert_position >= $total_paragraphs) { $skipped_count++; continue; } // Check if this position is after FAQ section start $byte_position = $paragraph_positions[$insert_position] ?? 0; if ($faq_start_pos !== false && $byte_position >= $faq_start_pos) { $skipped_count++; continue; // Skip this position, it's in or after FAQ section } $header = $header_suggestions[$i]; // Handle both old format (string) and new format (object with text + placement) $header_text = is_string($header) ? $header : (isset($header['text']) ? $header['text'] : ''); if (empty($header_text)) { continue; } $clean_header = wp_kses_post($header_text); // Wrap in H2 tags if not already wrapped if (stripos($header_text, '

    ') === false && stripos($header_text, '

    ') === false) { $header_html = "\n\n

    " . $clean_header . "

    \n\n"; } else { $header_html = "\n\n" . $clean_header . "\n\n"; } // Insert header after the paragraph at this position $paragraphs[$insert_position] .= $header_html; $inserted++; } // Reconstruct content $content = implode('', $paragraphs); // If no headers were inserted due to FAQ collision, insert them before FAQ if ($inserted === 0 && $header_count > 0) { $header_html = ''; foreach ($header_suggestions as $i => $header) { // Handle both old format (string) and new format (object with text + placement) $header_text = is_string($header) ? $header : (isset($header['text']) ? $header['text'] : ''); if (empty($header_text)) continue; $clean_header = wp_kses_post($header_text); if (stripos($header_text, '

    ') === false && stripos($header_text, '

    ') === false) { $header_html .= "

    " . $clean_header . "

    \n\n"; } else { $header_html .= $clean_header . "\n\n"; } } if ($faq_start_pos !== false) { // Insert before FAQ $content = substr($content, 0, $faq_start_pos) . $header_html . substr($content, $faq_start_pos); } else { // No FAQ, append to end $content .= "\n\n" . $header_html; } } } } // Add FAQ section if there are FAQ suggestions if (!empty($faq_suggestions)) { // Extract existing FAQ questions from content to avoid duplicates $existing_faq_questions = $this->extract_existing_faq_questions_from_content($content); // Deduplicate incoming FAQ suggestions against existing FAQs $unique_faq_suggestions = array(); $deduped_within_request = array(); // Track within this request foreach ($faq_suggestions as $faq) { $question = isset($faq['question']) ? $faq['question'] : ''; if (empty($question)) { continue; } $normalized_q = $this->normalize_faq_question($question); // Check if already exists in content $exists_in_content = false; foreach ($existing_faq_questions as $existing_normalized) { if ($this->faq_similarity($normalized_q, $existing_normalized) > 0.75) { $exists_in_content = true; break; } } // Check if already added in this batch (in case Apply is clicked multiple times) $duplicate_in_batch = false; foreach ($deduped_within_request as $batch_normalized) { if ($this->faq_similarity($normalized_q, $batch_normalized) > 0.75) { $duplicate_in_batch = true; break; } } // Only add if not duplicate if (!$exists_in_content && !$duplicate_in_batch) { $unique_faq_suggestions[] = $faq; $deduped_within_request[] = $normalized_q; } } // Only add FAQ section if we have at least one new unique FAQ if (!empty($unique_faq_suggestions)) { // Check if FAQ header exists after potential content insertion $has_faq_header = (stripos($content, 'frequently asked questions') !== false || stripos($content, '

    FAQ

    ') !== false); // Add FAQ header if not already present if (!$has_faq_header) { $content .= "\n\n

    Frequently Asked Questions

    "; } // Add each unique FAQ at the end foreach ($unique_faq_suggestions as $faq) { $clean_question = esc_html($faq['question']); // Strip underline tags from FAQ answers $answer_text = $faq['answer']; $answer_text = preg_replace('/(.*?)<\/u>/i', '$1', $answer_text); $answer_text = preg_replace('/]*>(.*?)<\/u>/i', '$1', $answer_text); $answer_text = preg_replace('/style\s*=\s*["\'][^"\']*text-decoration:\s*underline[^"\']*["\']/i', '', $answer_text); $clean_answer = wp_kses_post($answer_text); $content .= "\n\n

    Q: " . $clean_question . "

    \n

    A: " . $clean_answer . "

    "; } } } // Remove kses filters temporarily to allow our content kses_remove_filters(); // Build update array $update_data = array('ID' => $post_id); // Update title if selected and different from original if ($selected_title && $selected_title !== $title) { $update_data['post_title'] = $selected_title; $title_changed = true; } // Update content $update_data['post_content'] = $content; // Update post $result = wp_update_post($update_data, true); // Re-add kses filters kses_init_filters(); // Clear post cache clean_post_cache($post_id); if (is_wp_error($result)) { return false; } // Return array with success and updated content return array( 'success' => true, 'content' => $content, 'title' => $selected_title ? $selected_title : $title, 'title_changed' => $title_changed ); } /** * Check if post is built with Elementor * * @param int $post_id Post ID * @return bool True if post uses Elementor */ private function is_elementor_post($post_id) { // Check if Elementor plugin is active if (!defined('ELEMENTOR_VERSION') && !class_exists('\Elementor\Plugin')) { return false; } // Check if post was built with Elementor $elementor_data = get_post_meta($post_id, '_elementor_data', true); return !empty($elementor_data); } /** * Extract plain text from Elementor data for analysis * * @param int $post_id Post ID * @return string Extracted text content */ private function extract_text_from_elementor($post_id) { $elementor_data = get_post_meta($post_id, '_elementor_data', true); if (empty($elementor_data)) { return ''; } $data = json_decode($elementor_data, true); if (!$data || !is_array($data)) { return ''; } $text = ''; $this->extract_text_recursive($data, $text); return trim($text); } /** * Recursively extract text from Elementor widget data * * @param array $data Elementor widget data * @param string $text Reference to text accumulator */ private function extract_text_recursive($data, &$text) { if (is_array($data)) { foreach ($data as $key => $value) { // Common Elementor text fields if (in_array($key, array('editor', 'text', 'title', 'description', 'content', 'caption', 'heading', 'subheading'))) { if (is_string($value)) { $text .= ' ' . wp_strip_all_tags($value); } } elseif ($key === 'settings' && is_array($value)) { // Extract from settings object foreach ($value as $setting_key => $setting_value) { if (in_array($setting_key, array('editor', 'text', 'title', 'description', 'content', 'caption', 'heading', 'subheading'))) { if (is_string($setting_value)) { $text .= ' ' . wp_strip_all_tags($setting_value); } } } } elseif (is_array($value)) { $this->extract_text_recursive($value, $text); } } } } /** * Apply suggestions to Elementor document structure * ✅ v1.5.36: Use Elementor Document API to actually update visible content * * @param int $post_id Post ID * @param array $selected Selected suggestions * @param array $suggestions All suggestions * @return array|bool Result array or false on failure */ private function apply_suggestions_to_elementor($post_id, $selected, $suggestions) { // Check if Elementor is available if (!class_exists('\Elementor\Plugin')) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Elementor Plugin class not available' ); } // Get the Elementor document try { $document = \Elementor\Plugin::$instance->documents->get($post_id); if (!$document) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Could not get Elementor document' ); } if (!$document->is_built_with_elementor()) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Post not built with Elementor' ); } } catch (Exception $e) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Exception: ' . $e->getMessage() ); } // Get current Elementor data $elements = $document->get_elements_data(); if (empty($elements)) { $elements = array(); } // Build content to append $new_sections = array(); // Process selected suggestions foreach ($selected as $item) { $type = $item['type']; $index = intval($item['index']); if ($type === 'faq' && isset($suggestions['faqs'][$index])) { $faq = $suggestions['faqs'][$index]; // Create a new section with FAQ $new_sections[] = array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'section', 'settings' => array(), 'elements' => array( array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'column', 'settings' => array('_column_size' => 100), 'elements' => array( array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'widget', 'widgetType' => 'heading', 'settings' => array( 'title' => esc_html($faq['question']), 'header_size' => 'h3' ) ), array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'widget', 'widgetType' => 'text-editor', 'settings' => array( 'editor' => wp_kses_post($faq['answer']) ) ) ) ) ) ); } elseif ($type === 'content' && isset($suggestions['content'][$index])) { $content_item = $suggestions['content'][$index]; $content_text = is_array($content_item) && isset($content_item['rewrite']) ? $content_item['rewrite'] : $content_item; // Create a new section with content $new_sections[] = array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'section', 'settings' => array(), 'elements' => array( array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'column', 'settings' => array('_column_size' => 100), 'elements' => array( array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'widget', 'widgetType' => 'text-editor', 'settings' => array( 'editor' => wp_kses_post($content_text) ) ) ) ) ) ); } } if (empty($new_sections)) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'No sections were created from selected suggestions' ); } // Append new sections to existing elements $elements = array_merge($elements, $new_sections); // Save the updated document try { $document->save(array('elements' => $elements)); } catch (Exception $e) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Failed to save: ' . $e->getMessage() ); } return array( 'success' => true, 'elementor_updated' => true, 'sections_added' => count($new_sections) ); } }