PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 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.7.0, at includes/ai/class-metadata-generator.php

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