PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 releases
← All changes | includes/ai/class-metadata-generator.php +101 -28 1.0.1 → 2.9.0 View file →
@@ -49,9 +49,9 @@
49 49 * @param Settings|null $settings Settings instance
50 50 */
51 51 public function __construct(?Manager $ai_manager = null, ?Settings $settings = null) {
52 52 $this->ai_manager = $ai_manager ?? new Manager();
53 - $this->settings = $settings ?? new Settings();
53 + $this->settings = $settings ?? Settings::instance();
54 54 }
55 55
56 56 /**
57 57 * Generate metadata for post content
@@ -96,30 +96,47 @@
96 96 * @return array Generated metadata
97 97 * @throws \Exception If generation fails
98 98 */
99 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 {
100 123 $post = get_post($post_id);
101 -
124 +
102 125 if (!$post) {
103 126 throw new \Exception('Post not found');
104 127 }
105 -
128 +
106 129 // Extract content from post
107 130 $content = $this->extract_post_content($post);
108 -
131 +
109 132 // Add post-specific options
110 133 $options = array_merge($options, [
111 134 'content_type' => $this->determine_content_type($post),
112 135 'post_id' => $post_id,
113 136 ]);
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;
137 +
138 + return $this->generate_for_content($content, $options);
122 139 }
123 140
124 141 /**
125 142 * Generate multiple variations of metadata
@@ -202,11 +219,12 @@
202 219 // Trim and limit length for AI processing
203 220 $content = trim($content);
204 221 $max_length = 4000; // Reasonable limit for AI processing
205 222
206 - if (strlen($content) > $max_length) {
207 - $content = substr($content, 0, $max_length) . '...';
208 - }
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);
209 227
210 228 return $content;
211 229 }
212 230
@@ -252,31 +270,64 @@
252 270 * @param array $options Generation options
253 271 * @return array Enhanced metadata
254 272 */
255 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 +
256 280 // Validate title length
257 - if (strlen($metadata['title']) > $options['max_title_length']) {
258 - $metadata['title'] = substr($metadata['title'], 0, $options['max_title_length'] - 3) . '...';
281 + if (mb_strlen($metadata['title']) > $max_title_length) {
282 + $metadata['title'] = $this->truncate_text($metadata['title'], $max_title_length);
259 283 $metadata['title_truncated'] = true;
260 284 }
261 -
285 +
262 286 // Validate description length
263 - if (strlen($metadata['description']) > $options['max_description_length']) {
264 - $metadata['description'] = substr($metadata['description'], 0, $options['max_description_length'] - 3) . '...';
287 + if (mb_strlen($metadata['description']) > $max_description_length) {
288 + $metadata['description'] = $this->truncate_text($metadata['description'], $max_description_length);
265 289 $metadata['description_truncated'] = true;
266 290 }
267 -
291 +
268 292 // Add character counts
269 - $metadata['title_length'] = strlen($metadata['title']);
270 - $metadata['description_length'] = strlen($metadata['description']);
271 -
293 + $metadata['title_length'] = mb_strlen($metadata['title']);
294 + $metadata['description_length'] = mb_strlen($metadata['description']);
295 +
272 296 // Calculate optimization score
273 297 $metadata['optimization_score'] = $this->calculate_optimization_score($metadata, $options);
274 -
298 +
275 299 return $metadata;
276 300 }
277 -
301 +
278 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 + /**
279 330 * Save metadata to post meta
280 331 *
281 332 * @param int $post_id Post ID
282 333 * @param array $metadata Metadata to save
@@ -284,9 +335,13 @@
284 335 */
285 336 private function save_post_metadata(int $post_id, array $metadata): void {
286 337 update_post_meta($post_id, '_thinkrank_ai_title', $metadata['title']);
287 338 update_post_meta($post_id, '_thinkrank_ai_description', $metadata['description']);
288 - update_post_meta($post_id, '_thinkrank_focus_keyword', $metadata['focus_keyword']);
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 + }
289 344 update_post_meta($post_id, '_thinkrank_generated_at', $metadata['generated_at']);
290 345 update_post_meta($post_id, '_thinkrank_metadata_full', $metadata);
291 346 }
292 347
@@ -321,9 +376,27 @@
321 376 $words = str_word_count($text, 1);
322 377 $syllables = 0;
323 378
324 379 foreach ($words as $word) {
325 - $syllables += max(1, preg_match_all('/[aeiouy]+/i', $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);
326 399 }
327 400
328 401 return $syllables;
329 402 }