PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / ai / class-metadata-generator.php

class-metadata-generator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/ai/class-metadata-generator.php

503 lines 16.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Metadata Generator
4 *
5 * Core functionality for generating SEO metadata using AI
6 *
7 * @package ThinkRank\AI
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\AI;
14
15 use ThinkRank\Core\Settings;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Metadata Generator Class
24 *
25 * Single Responsibility: Generate SEO metadata using AI
26 *
27 * @since 1.0.0
28 */
29 class Metadata_Generator {
30
31 /**
32 * AI Manager instance
33 *
34 * @var Manager
35 */
36 private Manager $ai_manager;
37
38 /**
39 * Settings instance
40 *
41 * @var Settings
42 */
43 private Settings $settings;
44
45 /**
46 * Constructor
47 *
48 * @param Manager|null $ai_manager AI manager instance
49 * @param Settings|null $settings Settings instance
50 */
51 public function __construct(?Manager $ai_manager = null, ?Settings $settings = null) {
52 $this->ai_manager = $ai_manager ?? new Manager();
53 $this->settings = $settings ?? Settings::instance();
54 }
55
56 /**
57 * Generate metadata for post content
58 *
59 * @param string $content Post content
60 * @param array $options Generation options
61 * @return array Generated metadata
62 * @throws \Exception If generation fails
63 */
64 public function generate_for_content(string $content, array $options = []): array {
65 // Validate content
66 if (empty(trim($content))) {
67 throw new \Exception('Content cannot be empty');
68 }
69
70 // Prepare content for AI processing
71 $processed_content = $this->prepare_content($content);
72
73 // Set default options
74 $default_options = [
75 'target_keyword' => '',
76 'content_type' => 'blog_post',
77 'tone' => 'professional',
78 'max_title_length' => $this->settings->get('meta_title_length', 60),
79 'max_description_length' => $this->settings->get('meta_description_length', 160),
80 ];
81
82 $options = array_merge($default_options, $options);
83
84 // Generate metadata using AI
85 $metadata = $this->ai_manager->generate_seo_metadata($processed_content, $options);
86
87 // Post-process and validate metadata
88 return $this->validate_and_enhance_metadata($metadata, $options);
89 }
90
91 /**
92 * Generate metadata for WordPress post
93 *
94 * @param int $post_id Post ID
95 * @param array $options Generation options
96 * @return array Generated metadata
97 * @throws \Exception If generation fails
98 */
99 public function generate_for_post(int $post_id, array $options = []): array {
100 $metadata = $this->suggest_for_post($post_id, $options);
101
102 // Save metadata to post meta
103 $this->save_post_metadata($post_id, $metadata);
104
105 return $metadata;
106 }
107
108 /**
109 * Generate metadata for a post without writing anything.
110 *
111 * The same generation as {@see self::generate_for_post()}, minus the save.
112 * Bulk Snippets shows the result for review and writes only when the user
113 * clicks Save, so it must never persist as a side effect (#727).
114 *
115 * @since 2.8.0
116 *
117 * @param int $post_id Post ID.
118 * @param array $options Generation options.
119 * @return array Generated metadata.
120 * @throws \Exception If the post is missing or generation fails.
121 */
122 public function suggest_for_post(int $post_id, array $options = []): array {
123 $post = get_post($post_id);
124
125 if (!$post) {
126 throw new \Exception('Post not found');
127 }
128
129 // Extract content from post
130 $content = $this->extract_post_content($post);
131
132 // Add post-specific options
133 $options = array_merge($options, [
134 'content_type' => $this->determine_content_type($post),
135 'post_id' => $post_id,
136 ]);
137
138 return $this->generate_for_content($content, $options);
139 }
140
141 /**
142 * Generate multiple variations of metadata
143 *
144 * @param string $content Content to analyze
145 * @param array $options Generation options
146 * @param int $variations Number of variations to generate
147 * @return array Array of metadata variations
148 */
149 public function generate_variations(string $content, array $options = [], int $variations = 3): array {
150 $results = [];
151
152 for ($i = 0; $i < $variations; $i++) {
153 // Slightly vary the temperature for different results
154 $variation_options = array_merge($options, [
155 'temperature' => 0.3 + ($i * 0.2), // 0.3, 0.5, 0.7
156 'variation' => $i + 1,
157 ]);
158
159 try {
160 $metadata = $this->generate_for_content($content, $variation_options);
161 $metadata['variation_id'] = $i + 1;
162 $results[] = $metadata;
163
164 // Small delay to avoid rate limiting
165 if ($i < $variations - 1) {
166 sleep(1);
167 }
168
169 } catch (\Exception $e) {
170 // Skip failed variations silently
171 }
172 }
173
174 return $results;
175 }
176
177 /**
178 * Analyze content and suggest improvements
179 *
180 * @param string $content Content to analyze
181 * @param array $current_metadata Current metadata
182 * @return array Analysis and suggestions
183 */
184 public function analyze_content(string $content, array $current_metadata = []): array {
185 $analysis = [
186 'content_length' => str_word_count($content),
187 'readability_score' => $this->calculate_readability_score($content),
188 'keyword_density' => $this->analyze_keyword_density($content),
189 'suggestions' => [],
190 ];
191
192 // Analyze current metadata if provided
193 if (!empty($current_metadata)) {
194 $analysis['metadata_analysis'] = $this->analyze_metadata($current_metadata);
195 }
196
197 // Generate improvement suggestions
198 $analysis['suggestions'] = $this->generate_improvement_suggestions($analysis);
199
200 return $analysis;
201 }
202
203 /**
204 * Prepare content for AI processing
205 *
206 * @param string $content Raw content
207 * @return string Processed content
208 */
209 private function prepare_content(string $content): string {
210 // Remove HTML tags
211 $content = wp_strip_all_tags($content);
212
213 // Remove shortcodes
214 $content = strip_shortcodes($content);
215
216 // Normalize whitespace
217 $content = preg_replace('/\s+/', ' ', $content);
218
219 // Trim and limit length for AI processing
220 $content = trim($content);
221 $max_length = 4000; // Reasonable limit for AI processing
222
223 // strlen()/substr() count BYTES: on Thai or CJK this handed the model a
224 // third of the intended content, and cut the last character in half so
225 // the payload carried an invalid UTF-8 sequence (#687).
226 $content = \ThinkRank\Core\Seo_Text::trim_to_length($content, $max_length);
227
228 return $content;
229 }
230
231 /**
232 * Extract content from WordPress post
233 *
234 * @param \WP_Post $post Post object
235 * @return string Extracted content
236 */
237 private function extract_post_content(\WP_Post $post): string {
238 $content = $post->post_title . "\n\n" . $post->post_content;
239
240 // Add excerpt if available
241 if (!empty($post->post_excerpt)) {
242 $content = $post->post_title . "\n\n" . $post->post_excerpt . "\n\n" . $post->post_content;
243 }
244
245 return $content;
246 }
247
248 /**
249 * Determine content type from post
250 *
251 * @param \WP_Post $post Post object
252 * @return string Content type
253 */
254 private function determine_content_type(\WP_Post $post): string {
255 switch ($post->post_type) {
256 case 'page':
257 return 'page';
258 case 'product':
259 return 'product';
260 case 'post':
261 default:
262 return 'blog_post';
263 }
264 }
265
266 /**
267 * Validate and enhance generated metadata
268 *
269 * @param array $metadata Generated metadata
270 * @param array $options Generation options
271 * @return array Enhanced metadata
272 */
273 private function validate_and_enhance_metadata(array $metadata, array $options): array {
274 $max_title_length = max(1, (int) $options['max_title_length']);
275 $max_description_length = max(1, (int) $options['max_description_length']);
276
277 $metadata['title'] = trim((string) ($metadata['title'] ?? ''));
278 $metadata['description'] = trim((string) ($metadata['description'] ?? ''));
279
280 // Validate title length
281 if (mb_strlen($metadata['title']) > $max_title_length) {
282 $metadata['title'] = $this->truncate_text($metadata['title'], $max_title_length);
283 $metadata['title_truncated'] = true;
284 }
285
286 // Validate description length
287 if (mb_strlen($metadata['description']) > $max_description_length) {
288 $metadata['description'] = $this->truncate_text($metadata['description'], $max_description_length);
289 $metadata['description_truncated'] = true;
290 }
291
292 // Add character counts
293 $metadata['title_length'] = mb_strlen($metadata['title']);
294 $metadata['description_length'] = mb_strlen($metadata['description']);
295
296 // Calculate optimization score
297 $metadata['optimization_score'] = $this->calculate_optimization_score($metadata, $options);
298
299 return $metadata;
300 }
301
302 /**
303 * Truncate text to a hard character limit, preferring a word boundary
304 *
305 * The ellipsis counts towards the limit, so the result is never longer
306 * than $max_length characters.
307 *
308 * @param string $text Text to truncate
309 * @param int $max_length Maximum length in characters
310 * @return string Truncated text
311 */
312 private function truncate_text(string $text, int $max_length): string {
313 if (mb_strlen($text) <= $max_length) {
314 return $text;
315 }
316
317 // Reserve one character for the ellipsis.
318 $truncated = mb_substr($text, 0, $max_length - 1);
319
320 // Cut back to the last word boundary, unless that throws away too much.
321 $last_space = mb_strrpos($truncated, ' ');
322 if ($last_space !== false && $last_space > (int) ($max_length * 0.6)) {
323 $truncated = mb_substr($truncated, 0, $last_space);
324 }
325
326 return rtrim($truncated, " \t\n\r\0\x0B,;:-") . '…';
327 }
328
329 /**
330 * Save metadata to post meta
331 *
332 * @param int $post_id Post ID
333 * @param array $metadata Metadata to save
334 * @return void
335 */
336 private function save_post_metadata(int $post_id, array $metadata): void {
337 update_post_meta($post_id, '_thinkrank_ai_title', $metadata['title']);
338 update_post_meta($post_id, '_thinkrank_ai_description', $metadata['description']);
339 // Persist via Focus_Keywords so the array + legacy meta stay in sync.
340 // Existing keywords are preserved (the AI value seeds only when unset).
341 if (empty(\ThinkRank\SEO\Focus_Keywords::get($post_id))) {
342 \ThinkRank\SEO\Focus_Keywords::save($post_id, $metadata['focus_keyword']);
343 }
344 update_post_meta($post_id, '_thinkrank_generated_at', $metadata['generated_at']);
345 update_post_meta($post_id, '_thinkrank_metadata_full', $metadata);
346 }
347
348 /**
349 * Calculate basic readability score
350 *
351 * @param string $content Content to analyze
352 * @return float Readability score
353 */
354 private function calculate_readability_score(string $content): float {
355 $sentences = preg_split('/[.!?]+/', $content);
356 $words = str_word_count($content);
357 $syllables = $this->count_syllables($content);
358
359 if (count($sentences) === 0 || $words === 0) {
360 return 0;
361 }
362
363 // Simplified Flesch Reading Ease formula
364 $score = 206.835 - (1.015 * ($words / count($sentences))) - (84.6 * ($syllables / $words));
365
366 return max(0, min(100, $score));
367 }
368
369 /**
370 * Count syllables in text (simplified)
371 *
372 * @param string $text Text to analyze
373 * @return int Syllable count
374 */
375 private function count_syllables(string $text): int {
376 $words = str_word_count($text, 1);
377 $syllables = 0;
378
379 foreach ($words as $word) {
380 $word = preg_replace('/[^a-z]/', '', strtolower($word));
381 if ($word === '') {
382 continue;
383 }
384
385 $groups = preg_match_all('/[aeiouy]+/', $word);
386
387 // Standard Flesch heuristic: a trailing silent e does not form a
388 // syllable ("make", "time", "these") — but only when a consonant
389 // precedes it (a vowel+e ending like "movie" already shares its
390 // group) and never for consonant-le ("table"), which does count.
391 // Without this the counter inflated syllables/word by ~0.2-0.3 on
392 // ordinary prose, driving raw Flesch negative and the UI to a
393 // clamped "Very Difficult (0)" (#407).
394 if ($groups > 1 && preg_match('/[^aeiouy]e$/', $word) && !str_ends_with($word, 'le')) {
395 $groups--;
396 }
397
398 $syllables += max(1, $groups);
399 }
400
401 return $syllables;
402 }
403
404 /**
405 * Analyze keyword density
406 *
407 * @param string $content Content to analyze
408 * @return array Keyword density analysis
409 */
410 private function analyze_keyword_density(string $content): array {
411 $words = str_word_count(strtolower($content), 1);
412 $total_words = count($words);
413 $word_counts = array_count_values($words);
414
415 // Remove common stop words
416 $stop_words = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'];
417 $word_counts = array_diff_key($word_counts, array_flip($stop_words));
418
419 // Calculate density for top keywords
420 arsort($word_counts);
421 $top_keywords = array_slice($word_counts, 0, 10, true);
422
423 $density = [];
424 foreach ($top_keywords as $word => $count) {
425 $density[$word] = round(($count / $total_words) * 100, 2);
426 }
427
428 return $density;
429 }
430
431 /**
432 * Analyze existing metadata
433 *
434 * @param array $metadata Metadata to analyze
435 * @return array Analysis results
436 */
437 private function analyze_metadata(array $metadata): array {
438 return [
439 'title_length_ok' => strlen($metadata['title'] ?? '') <= 60,
440 'description_length_ok' => strlen($metadata['description'] ?? '') <= 160,
441 'has_focus_keyword' => !empty($metadata['focus_keyword'] ?? ''),
442 ];
443 }
444
445 /**
446 * Generate improvement suggestions
447 *
448 * @param array $analysis Content analysis
449 * @return array Suggestions
450 */
451 private function generate_improvement_suggestions(array $analysis): array {
452 $suggestions = [];
453
454 if ($analysis['content_length'] < 300) {
455 $suggestions[] = 'Consider adding more content. Longer content typically performs better in search results.';
456 }
457
458 if ($analysis['readability_score'] < 60) {
459 $suggestions[] = 'Content readability could be improved. Try using shorter sentences and simpler words.';
460 }
461
462 return $suggestions;
463 }
464
465 /**
466 * Calculate optimization score
467 *
468 * @param array $metadata Generated metadata
469 * @param array $options Generation options
470 * @return int Optimization score (0-100)
471 */
472 private function calculate_optimization_score(array $metadata, array $options): int {
473 $score = 0;
474
475 // Title optimization (30 points)
476 if (!empty($metadata['title'])) {
477 $score += 15;
478 if (strlen($metadata['title']) >= 30 && strlen($metadata['title']) <= 60) {
479 $score += 15;
480 }
481 }
482
483 // Description optimization (30 points)
484 if (!empty($metadata['description'])) {
485 $score += 15;
486 if (strlen($metadata['description']) >= 120 && strlen($metadata['description']) <= 160) {
487 $score += 15;
488 }
489 }
490
491 // Keyword optimization (40 points)
492 if (!empty($metadata['focus_keyword'])) {
493 $score += 20;
494 if (!empty($options['target_keyword']) &&
495 stripos($metadata['title'], $options['target_keyword']) !== false) {
496 $score += 20;
497 }
498 }
499
500 return min(100, $score);
501 }
502 }
503