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

485 lines 15.7 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 $word = preg_replace('/[^a-z]/', '', strtolower($word));
363 if ($word === '') {
364 continue;
365 }
366
367 $groups = preg_match_all('/[aeiouy]+/', $word);
368
369 // Standard Flesch heuristic: a trailing silent e does not form a
370 // syllable ("make", "time", "these") — but only when a consonant
371 // precedes it (a vowel+e ending like "movie" already shares its
372 // group) and never for consonant-le ("table"), which does count.
373 // Without this the counter inflated syllables/word by ~0.2-0.3 on
374 // ordinary prose, driving raw Flesch negative and the UI to a
375 // clamped "Very Difficult (0)" (#407).
376 if ($groups > 1 && preg_match('/[^aeiouy]e$/', $word) && !str_ends_with($word, 'le')) {
377 $groups--;
378 }
379
380 $syllables += max(1, $groups);
381 }
382
383 return $syllables;
384 }
385
386 /**
387 * Analyze keyword density
388 *
389 * @param string $content Content to analyze
390 * @return array Keyword density analysis
391 */
392 private function analyze_keyword_density(string $content): array {
393 $words = str_word_count(strtolower($content), 1);
394 $total_words = count($words);
395 $word_counts = array_count_values($words);
396
397 // Remove common stop words
398 $stop_words = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'];
399 $word_counts = array_diff_key($word_counts, array_flip($stop_words));
400
401 // Calculate density for top keywords
402 arsort($word_counts);
403 $top_keywords = array_slice($word_counts, 0, 10, true);
404
405 $density = [];
406 foreach ($top_keywords as $word => $count) {
407 $density[$word] = round(($count / $total_words) * 100, 2);
408 }
409
410 return $density;
411 }
412
413 /**
414 * Analyze existing metadata
415 *
416 * @param array $metadata Metadata to analyze
417 * @return array Analysis results
418 */
419 private function analyze_metadata(array $metadata): array {
420 return [
421 'title_length_ok' => strlen($metadata['title'] ?? '') <= 60,
422 'description_length_ok' => strlen($metadata['description'] ?? '') <= 160,
423 'has_focus_keyword' => !empty($metadata['focus_keyword'] ?? ''),
424 ];
425 }
426
427 /**
428 * Generate improvement suggestions
429 *
430 * @param array $analysis Content analysis
431 * @return array Suggestions
432 */
433 private function generate_improvement_suggestions(array $analysis): array {
434 $suggestions = [];
435
436 if ($analysis['content_length'] < 300) {
437 $suggestions[] = 'Consider adding more content. Longer content typically performs better in search results.';
438 }
439
440 if ($analysis['readability_score'] < 60) {
441 $suggestions[] = 'Content readability could be improved. Try using shorter sentences and simpler words.';
442 }
443
444 return $suggestions;
445 }
446
447 /**
448 * Calculate optimization score
449 *
450 * @param array $metadata Generated metadata
451 * @param array $options Generation options
452 * @return int Optimization score (0-100)
453 */
454 private function calculate_optimization_score(array $metadata, array $options): int {
455 $score = 0;
456
457 // Title optimization (30 points)
458 if (!empty($metadata['title'])) {
459 $score += 15;
460 if (strlen($metadata['title']) >= 30 && strlen($metadata['title']) <= 60) {
461 $score += 15;
462 }
463 }
464
465 // Description optimization (30 points)
466 if (!empty($metadata['description'])) {
467 $score += 15;
468 if (strlen($metadata['description']) >= 120 && strlen($metadata['description']) <= 160) {
469 $score += 15;
470 }
471 }
472
473 // Keyword optimization (40 points)
474 if (!empty($metadata['focus_keyword'])) {
475 $score += 20;
476 if (!empty($options['target_keyword']) &&
477 stripos($metadata['title'], $options['target_keyword']) !== false) {
478 $score += 20;
479 }
480 }
481
482 return min(100, $score);
483 }
484 }
485