PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.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 1.32.0, at includes/ai/class-metadata-generator.php

467 lines 14.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 if (strlen($content) > $max_length) {
207 $content = substr($content, 0, $max_length) . '...';
208 }
209
210 return $content;
211 }
212
213 /**
214 * Extract content from WordPress post
215 *
216 * @param \WP_Post $post Post object
217 * @return string Extracted content
218 */
219 private function extract_post_content(\WP_Post $post): string {
220 $content = $post->post_title . "\n\n" . $post->post_content;
221
222 // Add excerpt if available
223 if (!empty($post->post_excerpt)) {
224 $content = $post->post_title . "\n\n" . $post->post_excerpt . "\n\n" . $post->post_content;
225 }
226
227 return $content;
228 }
229
230 /**
231 * Determine content type from post
232 *
233 * @param \WP_Post $post Post object
234 * @return string Content type
235 */
236 private function determine_content_type(\WP_Post $post): string {
237 switch ($post->post_type) {
238 case 'page':
239 return 'page';
240 case 'product':
241 return 'product';
242 case 'post':
243 default:
244 return 'blog_post';
245 }
246 }
247
248 /**
249 * Validate and enhance generated metadata
250 *
251 * @param array $metadata Generated metadata
252 * @param array $options Generation options
253 * @return array Enhanced metadata
254 */
255 private function validate_and_enhance_metadata(array $metadata, array $options): array {
256 $max_title_length = max(1, (int) $options['max_title_length']);
257 $max_description_length = max(1, (int) $options['max_description_length']);
258
259 $metadata['title'] = trim((string) ($metadata['title'] ?? ''));
260 $metadata['description'] = trim((string) ($metadata['description'] ?? ''));
261
262 // Validate title length
263 if (mb_strlen($metadata['title']) > $max_title_length) {
264 $metadata['title'] = $this->truncate_text($metadata['title'], $max_title_length);
265 $metadata['title_truncated'] = true;
266 }
267
268 // Validate description length
269 if (mb_strlen($metadata['description']) > $max_description_length) {
270 $metadata['description'] = $this->truncate_text($metadata['description'], $max_description_length);
271 $metadata['description_truncated'] = true;
272 }
273
274 // Add character counts
275 $metadata['title_length'] = mb_strlen($metadata['title']);
276 $metadata['description_length'] = mb_strlen($metadata['description']);
277
278 // Calculate optimization score
279 $metadata['optimization_score'] = $this->calculate_optimization_score($metadata, $options);
280
281 return $metadata;
282 }
283
284 /**
285 * Truncate text to a hard character limit, preferring a word boundary
286 *
287 * The ellipsis counts towards the limit, so the result is never longer
288 * than $max_length characters.
289 *
290 * @param string $text Text to truncate
291 * @param int $max_length Maximum length in characters
292 * @return string Truncated text
293 */
294 private function truncate_text(string $text, int $max_length): string {
295 if (mb_strlen($text) <= $max_length) {
296 return $text;
297 }
298
299 // Reserve one character for the ellipsis.
300 $truncated = mb_substr($text, 0, $max_length - 1);
301
302 // Cut back to the last word boundary, unless that throws away too much.
303 $last_space = mb_strrpos($truncated, ' ');
304 if ($last_space !== false && $last_space > (int) ($max_length * 0.6)) {
305 $truncated = mb_substr($truncated, 0, $last_space);
306 }
307
308 return rtrim($truncated, " \t\n\r\0\x0B,;:-") . '';
309 }
310
311 /**
312 * Save metadata to post meta
313 *
314 * @param int $post_id Post ID
315 * @param array $metadata Metadata to save
316 * @return void
317 */
318 private function save_post_metadata(int $post_id, array $metadata): void {
319 update_post_meta($post_id, '_thinkrank_ai_title', $metadata['title']);
320 update_post_meta($post_id, '_thinkrank_ai_description', $metadata['description']);
321 // Persist via Focus_Keywords so the array + legacy meta stay in sync.
322 // Existing keywords are preserved (the AI value seeds only when unset).
323 if (empty(\ThinkRank\SEO\Focus_Keywords::get($post_id))) {
324 \ThinkRank\SEO\Focus_Keywords::save($post_id, $metadata['focus_keyword']);
325 }
326 update_post_meta($post_id, '_thinkrank_generated_at', $metadata['generated_at']);
327 update_post_meta($post_id, '_thinkrank_metadata_full', $metadata);
328 }
329
330 /**
331 * Calculate basic readability score
332 *
333 * @param string $content Content to analyze
334 * @return float Readability score
335 */
336 private function calculate_readability_score(string $content): float {
337 $sentences = preg_split('/[.!?]+/', $content);
338 $words = str_word_count($content);
339 $syllables = $this->count_syllables($content);
340
341 if (count($sentences) === 0 || $words === 0) {
342 return 0;
343 }
344
345 // Simplified Flesch Reading Ease formula
346 $score = 206.835 - (1.015 * ($words / count($sentences))) - (84.6 * ($syllables / $words));
347
348 return max(0, min(100, $score));
349 }
350
351 /**
352 * Count syllables in text (simplified)
353 *
354 * @param string $text Text to analyze
355 * @return int Syllable count
356 */
357 private function count_syllables(string $text): int {
358 $words = str_word_count($text, 1);
359 $syllables = 0;
360
361 foreach ($words as $word) {
362 $syllables += max(1, preg_match_all('/[aeiouy]+/i', $word));
363 }
364
365 return $syllables;
366 }
367
368 /**
369 * Analyze keyword density
370 *
371 * @param string $content Content to analyze
372 * @return array Keyword density analysis
373 */
374 private function analyze_keyword_density(string $content): array {
375 $words = str_word_count(strtolower($content), 1);
376 $total_words = count($words);
377 $word_counts = array_count_values($words);
378
379 // Remove common stop words
380 $stop_words = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'];
381 $word_counts = array_diff_key($word_counts, array_flip($stop_words));
382
383 // Calculate density for top keywords
384 arsort($word_counts);
385 $top_keywords = array_slice($word_counts, 0, 10, true);
386
387 $density = [];
388 foreach ($top_keywords as $word => $count) {
389 $density[$word] = round(($count / $total_words) * 100, 2);
390 }
391
392 return $density;
393 }
394
395 /**
396 * Analyze existing metadata
397 *
398 * @param array $metadata Metadata to analyze
399 * @return array Analysis results
400 */
401 private function analyze_metadata(array $metadata): array {
402 return [
403 'title_length_ok' => strlen($metadata['title'] ?? '') <= 60,
404 'description_length_ok' => strlen($metadata['description'] ?? '') <= 160,
405 'has_focus_keyword' => !empty($metadata['focus_keyword'] ?? ''),
406 ];
407 }
408
409 /**
410 * Generate improvement suggestions
411 *
412 * @param array $analysis Content analysis
413 * @return array Suggestions
414 */
415 private function generate_improvement_suggestions(array $analysis): array {
416 $suggestions = [];
417
418 if ($analysis['content_length'] < 300) {
419 $suggestions[] = 'Consider adding more content. Longer content typically performs better in search results.';
420 }
421
422 if ($analysis['readability_score'] < 60) {
423 $suggestions[] = 'Content readability could be improved. Try using shorter sentences and simpler words.';
424 }
425
426 return $suggestions;
427 }
428
429 /**
430 * Calculate optimization score
431 *
432 * @param array $metadata Generated metadata
433 * @param array $options Generation options
434 * @return int Optimization score (0-100)
435 */
436 private function calculate_optimization_score(array $metadata, array $options): int {
437 $score = 0;
438
439 // Title optimization (30 points)
440 if (!empty($metadata['title'])) {
441 $score += 15;
442 if (strlen($metadata['title']) >= 30 && strlen($metadata['title']) <= 60) {
443 $score += 15;
444 }
445 }
446
447 // Description optimization (30 points)
448 if (!empty($metadata['description'])) {
449 $score += 15;
450 if (strlen($metadata['description']) >= 120 && strlen($metadata['description']) <= 160) {
451 $score += 15;
452 }
453 }
454
455 // Keyword optimization (40 points)
456 if (!empty($metadata['focus_keyword'])) {
457 $score += 20;
458 if (!empty($options['target_keyword']) &&
459 stripos($metadata['title'], $options['target_keyword']) !== false) {
460 $score += 20;
461 }
462 }
463
464 return min(100, $score);
465 }
466 }
467