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
thinkrank / includes / api / class-seo-score-endpoint.php

class-seo-score-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.12.0, at includes/api/class-seo-score-endpoint.php

518 lines 20.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SEO Score REST API Endpoint
4 *
5 * Handles REST API endpoints for SEO score calculation and history
6 *
7 * @package ThinkRank\API
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\API;
14
15 use ThinkRank\AI\SEOScoreCalculator;
16 use ThinkRank\Core\Database;
17 use WP_REST_Request;
18 use WP_REST_Response;
19 use WP_Error;
20
21 // Prevent direct access
22 if (!defined('ABSPATH')) {
23 exit;
24 }
25
26 /**
27 * SEO Score Endpoint Class
28 *
29 * Provides REST API endpoints for:
30 * - /wp-json/thinkrank/v1/seo-score/calculate
31 * - /wp-json/thinkrank/v1/seo-score/history
32 *
33 * @since 1.0.0
34 */
35 class SEOScoreEndpoint {
36
37 /**
38 * SEO Score Calculator instance
39 *
40 * @var SEOScoreCalculator
41 */
42 private SEOScoreCalculator $calculator;
43
44 /**
45 * Constructor
46 *
47 * @param SEOScoreCalculator $calculator SEO Score Calculator instance
48 */
49 public function __construct(SEOScoreCalculator $calculator) {
50 $this->calculator = $calculator;
51 }
52
53 /**
54 * Initialize the endpoint
55 *
56 * @return void
57 */
58 public function init(): void {
59 add_action('rest_api_init', [$this, 'register_routes']);
60 }
61
62 /**
63 * Register REST API routes
64 *
65 * @return void
66 */
67 public function register_routes(): void {
68 // Calculate SEO score endpoint
69 register_rest_route('thinkrank/v1', '/seo-score/calculate', [
70 'methods' => 'POST',
71 'callback' => [$this, 'calculate_score'],
72 'permission_callback' => [$this, 'check_permissions'],
73 'args' => [
74 'post_id' => [
75 'required' => true,
76 'type' => 'integer',
77 'validate_callback' => [$this, 'validate_post_id'],
78 ],
79 'target_keyword' => [
80 'required' => false,
81 'type' => 'string',
82 'sanitize_callback' => 'sanitize_text_field',
83 ],
84 'target_keywords' => [
85 'required' => false,
86 'type' => 'array',
87 'items' => ['type' => 'string'],
88 'sanitize_callback' => function ($value) {
89 if (!is_array($value)) {
90 return [];
91 }
92 return array_map('sanitize_text_field', $value);
93 },
94 ],
95 'save_score' => [
96 'required' => false,
97 'type' => 'boolean',
98 'default' => true,
99 ],
100 'live_content' => [
101 'required' => false,
102 'type' => 'string',
103 'sanitize_callback' => 'wp_kses_post',
104 ],
105 // Unsaved SEO title/description straight from the editor. Both
106 // are scored factors, and without them an "Apply" that changes
107 // the title scores against the stale saved value — the score
108 // only moved once the post was saved, which read as the score
109 // updating minutes later on its own. Omitted (null) means "use
110 // what's saved"; an empty string means the user cleared the
111 // field, which must fall back to the rendered pattern exactly
112 // as an empty stored value does.
113 'live_title' => [
114 'required' => false,
115 'type' => 'string',
116 'sanitize_callback' => 'sanitize_text_field',
117 ],
118 'live_description' => [
119 'required' => false,
120 'type' => 'string',
121 'sanitize_callback' => 'sanitize_text_field',
122 ],
123 // The permalink field as it stands in the editor, so an unsaved
124 // slug edit scores immediately instead of matching the old URL.
125 'live_slug' => [
126 'required' => false,
127 'type' => 'string',
128 // NOT sanitize_title: REST calls a sanitize_callback as
129 // ($value, $request, $param), and sanitize_title()'s second
130 // parameter is $fallback_title — so an empty slug returned
131 // the WP_REST_Request object, which fataled on the string
132 // cast below. The editor sends an empty slug whenever the
133 // permalink field is blank (every draft), so this fired on
134 // ordinary use. Sanitized with a single-argument
135 // sanitize_title() where it is consumed instead.
136 'sanitize_callback' => 'sanitize_text_field',
137 ],
138 'readability_score' => [
139 'required' => false,
140 'type' => 'string',
141 'sanitize_callback' => 'sanitize_text_field',
142 ],
143 'content_quality' => [
144 'required' => false,
145 'type' => 'string',
146 'sanitize_callback' => 'sanitize_text_field',
147 ],
148 ],
149 ]);
150
151 // Get existing SEO score endpoint
152 register_rest_route('thinkrank/v1', '/seo-score/get', [
153 'methods' => 'GET',
154 'callback' => [$this, 'get_existing_score'],
155 'permission_callback' => [$this, 'check_permissions'],
156 'args' => [
157 'post_id' => [
158 'required' => true,
159 'type' => 'integer',
160 'validate_callback' => [$this, 'validate_post_id'],
161 ],
162 ],
163 ]);
164
165 // Get score history endpoint
166 register_rest_route('thinkrank/v1', '/seo-score/history', [
167 'methods' => 'GET',
168 'callback' => [$this, 'get_score_history'],
169 'permission_callback' => [$this, 'check_permissions'],
170 'args' => [
171 'post_id' => [
172 'required' => true,
173 'type' => 'integer',
174 'validate_callback' => [$this, 'validate_post_id'],
175 ],
176 'limit' => [
177 'required' => false,
178 'type' => 'integer',
179 'default' => 10,
180 'minimum' => 1,
181 'maximum' => 50,
182 ],
183 ],
184 ]);
185
186 // Get latest score endpoint
187 register_rest_route('thinkrank/v1', '/seo-score/latest', [
188 'methods' => 'GET',
189 'callback' => [$this, 'get_latest_score'],
190 'permission_callback' => [$this, 'check_permissions'],
191 'args' => [
192 'post_id' => [
193 'required' => true,
194 'type' => 'integer',
195 'validate_callback' => [$this, 'validate_post_id'],
196 ],
197 ],
198 ]);
199 }
200
201 /**
202 * Calculate SEO score for a post
203 *
204 * @param WP_REST_Request $request Request object
205 * @return WP_REST_Response|WP_Error Response object
206 */
207 public function calculate_score(WP_REST_Request $request) {
208 try {
209 $post_id = $request->get_param('post_id');
210 $target_keyword = $request->get_param('target_keyword') ?? '';
211 $target_keywords = $request->get_param('target_keywords');
212 $save_score = $request->get_param('save_score') ?? true;
213 $live_content = $request->get_param('live_content') ?? '';
214 $readability_score = $request->get_param('readability_score') ?? null;
215 $content_quality = $request->get_param('content_quality') ?? null;
216
217 // Analyze content - use live content if provided, otherwise saved content
218 if (!empty($live_content)) {
219 $content_data = $this->calculator->analyze_live_content($live_content, $post_id);
220 } else {
221 $content_data = $this->calculator->analyze_post_content($post_id);
222 }
223
224 if (empty($content_data)) {
225 return new WP_Error(
226 'post_not_found',
227 'Post not found or has no content',
228 ['status' => 404]
229 );
230 }
231
232 // Get post metadata. Score against the FINAL rendered SEO title/
233 // description — resolve any variable tags in a custom value, and fall
234 // back to the rendered Global/Bulk pattern when the field is empty, so
235 // the length-based scoring matches what the frontend actually outputs.
236 // Prefer the editor's live values when the request carried them, so
237 // an unsaved title/description edit scores immediately instead of
238 // waiting for the post to be saved. `null` means the request said
239 // nothing about the field, which keeps the saved value.
240 $live_title = $request->get_param('live_title');
241 $live_description = $request->get_param('live_description');
242
243 // Score the slug the editor is showing. Omitted (null) keeps the
244 // saved slug; an empty string means the field was cleared, which
245 // falls back to the title-derived slug exactly as an unsaved draft
246 // does inside the calculator.
247 $live_slug = $request->get_param('live_slug');
248 if ($live_slug !== null) {
249 $content_data['slug'] = sanitize_title((string) $live_slug);
250 }
251
252 $raw_title = $live_title !== null
253 ? (string) $live_title
254 : get_post_meta($post_id, '_thinkrank_seo_title', true);
255 $raw_description = $live_description !== null
256 ? (string) $live_description
257 : get_post_meta($post_id, '_thinkrank_meta_description', true);
258 $metadata = [
259 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_title, $post_id, 'title'),
260 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_description, $post_id, 'description'),
261 // Fallback source for the scorer when the request carries no
262 // keyword (e.g. a plain "score this post" call). An explicit
263 // request keyword still wins, so the editor keeps scoring
264 // unsaved keyword edits live.
265 'focus_keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
266 ];
267
268 // Calculate score. Prefer the multi-keyword list when provided; the
269 // calculator scores each keyword and returns the highest as final.
270 //
271 // Only forward keyword options when the request actually carried
272 // them. The editor always sends its live keyword state (including
273 // empty, when the user clears the field) and that must be honored
274 // verbatim; a request that mentions no keyword at all leaves the
275 // options untouched so the calculator falls back to the keywords
276 // stored on the post.
277 $score_options = [];
278 if ($request->get_param('target_keyword') !== null) {
279 $score_options['target_keyword'] = (string) $target_keyword;
280 }
281 if (is_array($target_keywords)) {
282 $score_options['target_keywords'] = $target_keywords;
283 }
284 $score_data = $this->calculator->calculate_score(
285 $content_data,
286 $metadata,
287 $score_options
288 );
289
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))) {
317 $score_data['readability_score'] = $readability_score;
318 }
319 if ($editor_measured && null !== $content_quality
320 && !($server_found_content && self::is_no_content_label($content_quality))) {
321 $score_data['content_quality'] = $content_quality;
322 }
323
324 // Save score if requested
325 if ($save_score) {
326 $user_id = get_current_user_id();
327 $score_id = $this->calculator->save_score($post_id, $user_id, $score_data);
328
329 if ($score_id) {
330 $score_data['score_id'] = $score_id;
331 }
332 }
333
334 return new WP_REST_Response([
335 'success' => true,
336 'data' => $score_data,
337 ], 200);
338
339 } catch (\Exception $e) {
340 return new WP_Error(
341 'calculation_failed',
342 'Failed to calculate SEO score: ' . $e->getMessage(),
343 ['status' => 500]
344 );
345 }
346 }
347
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 /**
366 * Get existing SEO score for a post
367 *
368 * @param WP_REST_Request $request Request object
369 * @return WP_REST_Response|WP_Error Response object
370 */
371 public function get_existing_score(WP_REST_Request $request) {
372 try {
373 $post_id = $request->get_param('post_id');
374
375 // Get existing score data from database
376 $existing_data = $this->calculator->get_existing_score_data($post_id);
377
378 if ($existing_data) {
379 $existing_data['keywords'] = $this->keyword_placements((int) $post_id);
380
381 return new WP_REST_Response([
382 'success' => true,
383 'data' => $existing_data,
384 'message' => __('Existing SEO score retrieved successfully', 'thinkrank')
385 ], 200);
386 } else {
387 return new WP_REST_Response([
388 'success' => false,
389 'data' => null,
390 'message' => __('No existing SEO analysis found', 'thinkrank')
391 ], 200); // 200 because it's not an error, just no data
392 }
393
394 } catch (\Exception $e) {
395 return new WP_Error('get_score_error', $e->getMessage(), ['status' => 500]);
396 }
397 }
398
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 /**
419 * Get score history for a post
420 *
421 * @param WP_REST_Request $request Request object
422 * @return WP_REST_Response|WP_Error Response object
423 */
424 public function get_score_history(WP_REST_Request $request) {
425 try {
426 $post_id = $request->get_param('post_id');
427 $limit = $request->get_param('limit') ?? 10;
428
429 $history = $this->calculator->get_score_history($post_id, $limit);
430
431 return new WP_REST_Response([
432 'success' => true,
433 'data' => $history,
434 ], 200);
435
436 } catch (\Exception $e) {
437 return new WP_Error(
438 'history_failed',
439 'Failed to retrieve score history: ' . $e->getMessage(),
440 ['status' => 500]
441 );
442 }
443 }
444
445 /**
446 * Get latest score for a post
447 *
448 * @param WP_REST_Request $request Request object
449 * @return WP_REST_Response|WP_Error Response object
450 */
451 public function get_latest_score(WP_REST_Request $request) {
452 try {
453 $post_id = $request->get_param('post_id');
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.
459 $latest_score = $this->calculator->get_latest_score($post_id);
460
461 return new WP_REST_Response([
462 'success' => true,
463 'data' => $latest_score,
464 ], 200);
465
466 } catch (\Exception $e) {
467 return new WP_Error(
468 'latest_failed',
469 'Failed to retrieve latest score: ' . $e->getMessage(),
470 ['status' => 500]
471 );
472 }
473 }
474
475 /**
476 * Check permissions for API access
477 *
478 * @param WP_REST_Request $request Request object
479 * @return bool True if user has permission
480 */
481 public function check_permissions(WP_REST_Request $request): bool {
482 // Check if user is logged in
483 if (!is_user_logged_in()) {
484 return false;
485 }
486
487 // Check if user can edit posts
488 if (!current_user_can('edit_posts')) {
489 return false;
490 }
491
492 // For specific post operations, check if user can edit the specific post
493 $post_id = $request->get_param('post_id');
494 if ($post_id && !current_user_can('edit_post', $post_id)) {
495 return false;
496 }
497
498 return true;
499 }
500
501 /**
502 * Validate post ID parameter
503 *
504 * @param mixed $value Parameter value
505 * @param WP_REST_Request $request Request object
506 * @param string $param Parameter name
507 * @return bool True if valid
508 */
509 public function validate_post_id($value, WP_REST_Request $request, string $param): bool {
510 if (!is_numeric($value) || $value <= 0) {
511 return false;
512 }
513
514 $post = get_post((int) $value);
515 return $post !== null;
516 }
517 }
518