PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.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 All 53 releases
← All changes | includes/api/class-seo-score-endpoint.php +72 -4 2.0.0 → 2.12.0 View file →
@@ -286,13 +286,39 @@
286 286 $metadata,
287 287 $score_options
288 288 );
289 289
290 - // Add readability_score and content_quality from frontend if provided
291 - if (!empty($readability_score)) {
290 + // Add readability_score and content_quality from frontend if
291 + // provided. `!== null`, not `!empty()`: 0 is a legitimate score and
292 + // empty() discarded it, so a post the editor scored as 0 kept
293 + // whatever the calculator had produced instead (#394). Both params
294 + // already default to null above, so null means "not sent".
295 + //
296 + // Only when the editor actually measured something, though. Inside a
297 + // page builder there is no editor content to read, so Refresh sent an
298 + // empty `live_content` along with the two labels derived from that
299 + // empty string — the literal words "No content" — and they were
300 + // applied over the correct server-side analysis this request had just
301 + // run, then persisted by save_score(). That is what put "No content"
302 + // on the dashboard for a page with a valid score (#778). With nothing
303 + // measured, the analysis above is the better answer.
304 + //
305 + // Non-empty `live_content` is not on its own proof that the editor
306 + // measured anything: builder markup (Divi 5) is markup the editor
307 + // has plenty of and can read no words from, so it sent that same
308 + // "No content" pair alongside 450 characters of block comments.
309 + // Treat the sentinel itself as "nothing measured" whenever this
310 + // request's own analysis did find words, so a stale bundle cannot
311 + // overwrite a valid analysis either.
312 + $editor_measured = '' !== trim((string) $live_content);
313 + $server_found_content = ($content_data['word_count'] ?? 0) > 0;
314 +
315 + if ($editor_measured && null !== $readability_score
316 + && !($server_found_content && self::is_no_content_label($readability_score))) {
292 317 $score_data['readability_score'] = $readability_score;
293 318 }
294 - if (!empty($content_quality)) {
319 + if ($editor_measured && null !== $content_quality
320 + && !($server_found_content && self::is_no_content_label($content_quality))) {
295 321 $score_data['content_quality'] = $content_quality;
296 322 }
297 323
298 324 // Save score if requested
@@ -319,8 +345,25 @@
319 345 }
320 346 }
321 347
322 348 /**
349 + * Whether an editor-supplied label means "I could not read the content".
350 + *
351 + * `calculateReadabilityScore()` and `calculateContentQuality()` both return
352 + * this exact untranslated string when handed an empty body, so it is the
353 + * editor saying it measured nothing rather than a measurement in its own
354 + * right. Every real label carries a level or a grade.
355 + *
356 + * @since 2.12.0
357 + *
358 + * @param mixed $label Label sent by the editor.
359 + * @return bool True when the label is the empty-content sentinel.
360 + */
361 + private static function is_no_content_label($label): bool {
362 + return is_string($label) && 'no content' === strtolower(trim($label));
363 + }
364 +
365 + /**
323 366 * Get existing SEO score for a post
324 367 *
325 368 * @param WP_REST_Request $request Request object
326 369 * @return WP_REST_Response|WP_Error Response object
@@ -332,8 +375,10 @@
332 375 // Get existing score data from database
333 376 $existing_data = $this->calculator->get_existing_score_data($post_id);
334 377
335 378 if ($existing_data) {
379 + $existing_data['keywords'] = $this->keyword_placements((int) $post_id);
380 +
336 381 return new WP_REST_Response([
337 382 'success' => true,
338 383 'data' => $existing_data,
339 384 'message' => __('Existing SEO score retrieved successfully', 'thinkrank')
@@ -351,8 +396,27 @@
351 396 }
352 397 }
353 398
354 399 /**
400 + * Where each focus keyword sits (#729), for a stored score. A stored score
401 + * predates the editor's current keywords and content, so this is read
402 + * from the post as saved now — a text pass, no request, no AI.
403 + *
404 + * @param int $post_id Post ID.
405 + * @return array<int, array<string, mixed>>
406 + */
407 + private function keyword_placements(int $post_id): array {
408 + return $this->calculator->keyword_placements(
409 + $this->calculator->analyze_post_content($post_id),
410 + [
411 + 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id),
412 + 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id),
413 + ],
414 + \ThinkRank\SEO\Focus_Keywords::get($post_id)
415 + );
416 + }
417 +
418 + /**
355 419 * Get score history for a post
356 420 *
357 421 * @param WP_REST_Request $request Request object
358 422 * @return WP_REST_Response|WP_Error Response object
@@ -387,10 +451,14 @@
387 451 public function get_latest_score(WP_REST_Request $request) {
388 452 try {
389 453 $post_id = $request->get_param('post_id');
390 454
455 + // The stored row only — no keyword gauges (#729). The dashboard
456 + // asks for five posts at once and reads overall_score; a gauge
457 + // pass would analyse and render each whole post for nothing. The
458 + // editor reads its gauges from /seo-score/get.
391 459 $latest_score = $this->calculator->get_latest_score($post_id);
392 -
460 +
393 461 return new WP_REST_Response([
394 462 'success' => true,
395 463 'data' => $latest_score,
396 464 ], 200);