| 1 |
<?php |
| 2 |
|
| 3 |
class Meow_MWSEO_Modules_Suggestions |
| 4 |
{ |
| 5 |
// TODO: Migrate $meta_key_seo_title to '_mwseo_title' and $meta_key_seo_excerpt to '_mwseo_excerpt' |
| 6 |
public static function prompt($post, $field, $customContent = null, $max_tokens = 500, $meta_key_seo_title = '_kiss_seo_title', $meta_key_seo_excerpt = '_kiss_seo_excerpt', $core = null ){ |
| 7 |
// Get the global core instance if not provided |
| 8 |
if ( is_null( $core ) ) { |
| 9 |
global $mwseo_core; |
| 10 |
$core = $mwseo_core; |
| 11 |
// Fallback if global is not set (shouldn't happen in normal operation) |
| 12 |
if ( is_null( $core ) ) { |
| 13 |
throw new Exception( 'SEO Engine Core is not initialized.' ); |
| 14 |
} |
| 15 |
} |
| 16 |
global $mwai; |
| 17 |
|
| 18 |
if (is_null( $mwai ) || !isset( $mwai ) ) { |
| 19 |
$core->log( "⚠️ Missing AI Engine." ); |
| 20 |
return false; |
| 21 |
} |
| 22 |
|
| 23 |
$language = $core->get_post_language_name( $post->ID ); |
| 24 |
$ai_keywords = $core->get_option( 'ai_keywords', false ); |
| 25 |
|
| 26 |
$originalContent = $customContent?? Meow_MWSEO_Modules_Suggestions::get_post_sample_context( $post, $core ); |
| 27 |
$prompt = ""; |
| 28 |
$max_tokens = $max_tokens; |
| 29 |
$expected_size = 0; |
| 30 |
|
| 31 |
|
| 32 |
|
| 33 |
switch ($field){ |
| 34 |
//EDIT FIELDS |
| 35 |
case 'title_not_unique': |
| 36 |
case 'title_missing': |
| 37 |
case 'title': |
| 38 |
$prompt = sprintf( "Given the original title: \"%s\" and a sample from the post content : \"%s\", formulate a new, captivating, SEO-optimized title.", $post->post_title, $originalContent ); |
| 39 |
$expected_size = 80; |
| 40 |
break; |
| 41 |
case 'excerpt_missing': |
| 42 |
case 'excerpt': |
| 43 |
$prompt = sprintf( "Given the original excerpt: \"%s\" and a sample from the post content : \"%s\", write a concise, engaging and SEO-friendly excerpt. Ideally between 100 and 140 characters. It should be absolutely CONCISE and LESS than 160 characters.", $post->post_excerpt, $originalContent ); |
| 44 |
$expected_size = 160; |
| 45 |
break; |
| 46 |
case 'title_seo_length': |
| 47 |
case 'seo_title': |
| 48 |
$prompt = sprintf( "For the website \"%s\". Given the original SEO title: \"%s\" and a sample from the post content : \"%s\", create a new, SEO-optimized title that will attract more clicks. The format should be \"<short_title_keywords> | <site name>\". Ideally between 40 and 50 characters. It should be absolutely CONCISE and LESS than 70 characters.", get_bloginfo( 'name' ), get_post_meta( $post->ID, $meta_key_seo_title, true ), $originalContent ); |
| 49 |
$expected_size = 60; |
| 50 |
break; |
| 51 |
case 'excerpt_seo_length': |
| 52 |
case 'seo_excerpt': |
| 53 |
$prompt = sprintf( "For the website \"%s\". Given the original SEO excerpt: \"%s\" and a sample from the post content : \"%s\", write a new, SEO-friendly excerpt that will entice readers to click. The most important keywords should be at the beginning of the excerpt. Ideally between 80 and 100 characters. It should be absolutely CONCISE and LESS than 120 characters.", get_bloginfo( 'name' ), get_post_meta( $post->ID, $meta_key_seo_excerpt, true ) , $originalContent ); |
| 54 |
$expected_size = 100; |
| 55 |
break; |
| 56 |
case 'slug_words': |
| 57 |
case 'slug_length': |
| 58 |
case 'slug': |
| 59 |
$prompt = sprintf( "Given the original slug: \"%s\" and a sample from the post content : \"%s\". Create a new, SEO-friendly 4 words slug that is less than 64 chars in the format <keyword_1>-<keyword_2>-<keyword_3>-<keyword_4>", $post->post_name, $originalContent ); |
| 60 |
$expected_size = 60; |
| 61 |
break; |
| 62 |
|
| 63 |
//MAGIC FIXES |
| 64 |
|
| 65 |
case 'magic_fix_images_missing_alt_text': |
| 66 |
$prompt = sprintf( "Given the original title: \"%s\" and paragraph above the image : \"%s\", create a new, SEO-friendly description that will add alt text to the image. (one sentence with 5 to 10 words max). New alt text could be :", $post->post_title, $originalContent ); |
| 67 |
break; |
| 68 |
case 'links_missing_internal': |
| 69 |
|
| 70 |
// Get keywords from the post. |
| 71 |
$keywords = []; |
| 72 |
if ( $ai_keywords ) { |
| 73 |
$keywords = get_post_meta( $post->ID, '_mwseo_keywords', true ); |
| 74 |
} |
| 75 |
|
| 76 |
if ( empty( $keywords ) || !is_array( $keywords ) ) { |
| 77 |
$keywords_prompt = sprintf(" Givent the content of the post : \"%s\", identify the main keywords that are relevant to the post, they will be used to search the website for realted posts using WordPress search. Return a JSON array with the keywords, like this : {\"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\"]}.", $originalContent ); |
| 78 |
$keywords_json = $mwai->simpleJsonQuery( $keywords_prompt ); |
| 79 |
$keywords = $keywords_json['keywords'] ?? []; |
| 80 |
} |
| 81 |
|
| 82 |
if ( empty( $keywords ) ) { |
| 83 |
$core->log( "⚠️ No keywords found for the post." ); |
| 84 |
return false; |
| 85 |
} |
| 86 |
|
| 87 |
// Gather the posts that match the keywords, in the post's own language. |
| 88 |
$posts = get_posts( $core->apply_language_filter( [ |
| 89 |
's' => implode( ' ', $keywords ), |
| 90 |
'post_type' => 'any', |
| 91 |
'post_status' => 'publish', |
| 92 |
'numberposts' => 5, |
| 93 |
], $core->get_post_language_slug( $post->ID ) ) ); |
| 94 |
|
| 95 |
if ( empty( $posts ) ) { |
| 96 |
$core->log( "⚠️ No posts found for the keywords." ); |
| 97 |
return false; |
| 98 |
} |
| 99 |
|
| 100 |
$posts_list = []; |
| 101 |
foreach ( $posts as $related_post ) { |
| 102 |
// Skip the current post. |
| 103 |
if ( $related_post->ID === $post->ID ) { |
| 104 |
continue; |
| 105 |
} |
| 106 |
|
| 107 |
$posts_list[] = sprintf( "<a href=\"%s\" target=\"_blank\">%s</a> (Excerpt: \"%s\")", get_permalink( $related_post->ID ), $related_post->post_title, wp_trim_words( $related_post->post_content, 20 ) ); |
| 108 |
} |
| 109 |
|
| 110 |
if ( empty( $posts_list ) ) { |
| 111 |
$core->log( "⚠️ No related posts found for the keywords." ); |
| 112 |
return false; |
| 113 |
} |
| 114 |
|
| 115 |
// Create the prompt. |
| 116 |
$prompt = sprintf( "Given the original title: \"%s\" and a sample from the post content : \"%s\", create a new, SEO-friendly paragraph \"Check out our other articles\" that will add a few internal links to related posts on the website. The format should be natural, very human, something along \"We are also talking about [keyword] on %s\". Do NOT include links that are not provided, only use the one provided here. The Excerpt is just for you to have context about these posts, do NOT include it in the paragraphe. Include the links using the <a> element as provided.", $post->post_title, $originalContent, implode( ', ', $posts_list ) ); |
| 117 |
break; |
| 118 |
|
| 119 |
case 'links_missing_external': |
| 120 |
$prompt = sprintf( "Given the original title: \"%s\" and the post content: \"%s\", identify 2-3 existing text phrases from the content that would benefit from external Wikipedia links. For each phrase, find a relevant Wikipedia article. Return ONLY the HTML with the linked text using this format: <a href=\"[wikipedia_url]\" target=\"_blank\">[exact_phrase_from_content]</a> (one per line). The phrases MUST exist in the original content exactly as written. Choose phrases that are concepts, topics, or terms that readers would want to learn more about.", $post->post_title, $originalContent ); |
| 121 |
break; |
| 122 |
|
| 123 |
//DEFAULT |
| 124 |
default: |
| 125 |
$core->log( "⚠️ Unknown field ($field), can't prompt AI." ); |
| 126 |
return false; |
| 127 |
} |
| 128 |
|
| 129 |
if ( $ai_keywords ) { |
| 130 |
$keywords = get_post_meta( $post->ID, '_mwseo_keywords', true ); |
| 131 |
if ( !empty( $keywords ) && is_array( $keywords ) ) { |
| 132 |
$prompt = sprintf( '%s. The user wants this keywords to be what guides your suggestion : %s.', $prompt, implode(', ', $keywords) ); |
| 133 |
} |
| 134 |
} |
| 135 |
|
| 136 |
$prompt = sprintf( '<instructions>Reply only with the asked content, no other text or explanations, nothing else. Your reply should be in %s.</instructions> <prompt>%s</prompt>', $language, $prompt ); |
| 137 |
|
| 138 |
try { |
| 139 |
$ai_suggestion = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] ); |
| 140 |
$ai_suggestion = Meow_MWSEO_Modules_Suggestions::verify_ai_suggestion( $ai_suggestion, $expected_size, $language, $core ); |
| 141 |
} catch ( Exception $e ) { |
| 142 |
$core->log( "⚠️ AI Engine Error: " . $e->getMessage() ); |
| 143 |
throw new Exception( "AI Engine failed to generate suggestion. Please check your AI Engine configuration. Error: " . $e->getMessage() ); |
| 144 |
} |
| 145 |
|
| 146 |
|
| 147 |
//sanitize ai suggestion if needed |
| 148 |
switch ($field){ |
| 149 |
case 'slug_length': |
| 150 |
case 'slug_words': |
| 151 |
case 'slug': |
| 152 |
$ai_suggestion = sanitize_title( $ai_suggestion ); |
| 153 |
break; |
| 154 |
|
| 155 |
case 'links_missing_internal': |
| 156 |
case 'links_missing_external': |
| 157 |
// For internal links, convert to block editor format (appended as new paragraph). |
| 158 |
// For external links, keep raw HTML (used for search/replace). |
| 159 |
if ( $field === 'links_missing_internal' ) { |
| 160 |
$ai_suggestion = sprintf( "<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", $ai_suggestion ); |
| 161 |
} |
| 162 |
break; |
| 163 |
case 'excerpt_seo_length': |
| 164 |
case 'seo_excerpt': |
| 165 |
case 'excerpt_missing': |
| 166 |
case 'excerpt': |
| 167 |
$ai_suggestion = preg_replace('/.*\n/', '', $ai_suggestion, 1); |
| 168 |
default: |
| 169 |
break; |
| 170 |
} |
| 171 |
|
| 172 |
return $ai_suggestion; |
| 173 |
} |
| 174 |
|
| 175 |
public static function verify_ai_suggestion( $ai_suggestion, $expected_size, $language, $core = null ) { |
| 176 |
// Get the global core instance if not provided |
| 177 |
if ( is_null( $core ) ) { |
| 178 |
global $mwseo_core; |
| 179 |
$core = $mwseo_core; |
| 180 |
} |
| 181 |
$is_auto_correct_enabled = get_option( 'mwseo_options', null )[ 'ai_auto_correct' ] ?? false; |
| 182 |
|
| 183 |
if ( strlen( $ai_suggestion ) > $expected_size && $expected_size > 0 && $is_auto_correct_enabled) { |
| 184 |
$core->log( "⚠️ AI Suggestion too long." ); |
| 185 |
|
| 186 |
$prompt = sprintf('In %s, simplistically paraphrase the following "%s" (currently a %d characters string) into a string with less than %d characters, and ensure that its essential concepts and format are retained. Only the reduced form is required. Shortened string is: ', $language, $ai_suggestion, strlen($ai_suggestion), $expected_size); |
| 187 |
|
| 188 |
global $mwai; |
| 189 |
$ai_suggestion = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] ); |
| 190 |
} |
| 191 |
|
| 192 |
return $ai_suggestion; |
| 193 |
} |
| 194 |
|
| 195 |
public static function get_post_sample_context( $post, $core = null ){ |
| 196 |
// Get the global core instance if not provided |
| 197 |
if ( is_null( $core ) ) { |
| 198 |
global $mwseo_core; |
| 199 |
$core = $mwseo_core; |
| 200 |
} |
| 201 |
|
| 202 |
// Live content (when enabled) is applied via the mwseo_post_content filter. |
| 203 |
$content = apply_filters( 'mwseo_post_content', strip_tags( $post->post_content ), $post, true ); |
| 204 |
|
| 205 |
$words = str_word_count( $content, 1 ); |
| 206 |
$words_count = count( $words ); |
| 207 |
|
| 208 |
// If the post is less than 300 words, get the whole content. |
| 209 |
if ( $words_count < 300 ) { |
| 210 |
return $content; |
| 211 |
} |
| 212 |
|
| 213 |
// Get a total of 300 words from the post, 100 from the beginning, 100 from the middle and 100 from the end. |
| 214 |
$words_per_section = 200; |
| 215 |
$words_per_section = $words_per_section > $words_count ? $words_count : $words_per_section; |
| 216 |
$words_per_section = $words_per_section < 50 ? 50 : $words_per_section; |
| 217 |
|
| 218 |
$words_beginning = array_slice( $words, 0, $words_per_section ); |
| 219 |
$words_middle = array_slice( $words, floor( $words_count / 2 ) - floor( $words_per_section / 2 ), $words_per_section ); |
| 220 |
$words_end = array_slice( $words, $words_count - $words_per_section, $words_per_section ); |
| 221 |
|
| 222 |
$context = sprintf( |
| 223 |
"Beginning of content : \"%s\". Middle of content : \"%s\". End of content : \"%s\".", |
| 224 |
implode( ' ', $words_beginning ), |
| 225 |
implode( ' ', $words_middle ), |
| 226 |
implode( ' ', $words_end ) |
| 227 |
); |
| 228 |
|
| 229 |
return $context; |
| 230 |
} |
| 231 |
} |