PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.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 / 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 1.28.0, at includes/api/class-seo-score-endpoint.php

426 lines 15.1 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 'readability_score' => [
124 'required' => false,
125 'type' => 'string',
126 'sanitize_callback' => 'sanitize_text_field',
127 ],
128 'content_quality' => [
129 'required' => false,
130 'type' => 'string',
131 'sanitize_callback' => 'sanitize_text_field',
132 ],
133 ],
134 ]);
135
136 // Get existing SEO score endpoint
137 register_rest_route('thinkrank/v1', '/seo-score/get', [
138 'methods' => 'GET',
139 'callback' => [$this, 'get_existing_score'],
140 'permission_callback' => [$this, 'check_permissions'],
141 'args' => [
142 'post_id' => [
143 'required' => true,
144 'type' => 'integer',
145 'validate_callback' => [$this, 'validate_post_id'],
146 ],
147 ],
148 ]);
149
150 // Get score history endpoint
151 register_rest_route('thinkrank/v1', '/seo-score/history', [
152 'methods' => 'GET',
153 'callback' => [$this, 'get_score_history'],
154 'permission_callback' => [$this, 'check_permissions'],
155 'args' => [
156 'post_id' => [
157 'required' => true,
158 'type' => 'integer',
159 'validate_callback' => [$this, 'validate_post_id'],
160 ],
161 'limit' => [
162 'required' => false,
163 'type' => 'integer',
164 'default' => 10,
165 'minimum' => 1,
166 'maximum' => 50,
167 ],
168 ],
169 ]);
170
171 // Get latest score endpoint
172 register_rest_route('thinkrank/v1', '/seo-score/latest', [
173 'methods' => 'GET',
174 'callback' => [$this, 'get_latest_score'],
175 'permission_callback' => [$this, 'check_permissions'],
176 'args' => [
177 'post_id' => [
178 'required' => true,
179 'type' => 'integer',
180 'validate_callback' => [$this, 'validate_post_id'],
181 ],
182 ],
183 ]);
184 }
185
186 /**
187 * Calculate SEO score for a post
188 *
189 * @param WP_REST_Request $request Request object
190 * @return WP_REST_Response|WP_Error Response object
191 */
192 public function calculate_score(WP_REST_Request $request) {
193 try {
194 $post_id = $request->get_param('post_id');
195 $target_keyword = $request->get_param('target_keyword') ?? '';
196 $target_keywords = $request->get_param('target_keywords');
197 $save_score = $request->get_param('save_score') ?? true;
198 $live_content = $request->get_param('live_content') ?? '';
199 $readability_score = $request->get_param('readability_score') ?? null;
200 $content_quality = $request->get_param('content_quality') ?? null;
201
202 // Analyze content - use live content if provided, otherwise saved content
203 if (!empty($live_content)) {
204 $content_data = $this->calculator->analyze_live_content($live_content, $post_id);
205 } else {
206 $content_data = $this->calculator->analyze_post_content($post_id);
207 }
208
209 if (empty($content_data)) {
210 return new WP_Error(
211 'post_not_found',
212 'Post not found or has no content',
213 ['status' => 404]
214 );
215 }
216
217 // Get post metadata. Score against the FINAL rendered SEO title/
218 // description — resolve any variable tags in a custom value, and fall
219 // back to the rendered Global/Bulk pattern when the field is empty, so
220 // the length-based scoring matches what the frontend actually outputs.
221 // Prefer the editor's live values when the request carried them, so
222 // an unsaved title/description edit scores immediately instead of
223 // waiting for the post to be saved. `null` means the request said
224 // nothing about the field, which keeps the saved value.
225 $live_title = $request->get_param('live_title');
226 $live_description = $request->get_param('live_description');
227
228 $raw_title = $live_title !== null
229 ? (string) $live_title
230 : get_post_meta($post_id, '_thinkrank_seo_title', true);
231 $raw_description = $live_description !== null
232 ? (string) $live_description
233 : get_post_meta($post_id, '_thinkrank_meta_description', true);
234 $metadata = [
235 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_title, $post_id, 'title'),
236 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_description, $post_id, 'description'),
237 // Fallback source for the scorer when the request carries no
238 // keyword (e.g. a plain "score this post" call). An explicit
239 // request keyword still wins, so the editor keeps scoring
240 // unsaved keyword edits live.
241 'focus_keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
242 ];
243
244 // Calculate score. Prefer the multi-keyword list when provided; the
245 // calculator scores each keyword and returns the highest as final.
246 //
247 // Only forward keyword options when the request actually carried
248 // them. The editor always sends its live keyword state (including
249 // empty, when the user clears the field) and that must be honored
250 // verbatim; a request that mentions no keyword at all leaves the
251 // options untouched so the calculator falls back to the keywords
252 // stored on the post.
253 $score_options = [];
254 if ($request->get_param('target_keyword') !== null) {
255 $score_options['target_keyword'] = (string) $target_keyword;
256 }
257 if (is_array($target_keywords)) {
258 $score_options['target_keywords'] = $target_keywords;
259 }
260 $score_data = $this->calculator->calculate_score(
261 $content_data,
262 $metadata,
263 $score_options
264 );
265
266 // Add readability_score and content_quality from frontend if provided
267 if (!empty($readability_score)) {
268 $score_data['readability_score'] = $readability_score;
269 }
270 if (!empty($content_quality)) {
271 $score_data['content_quality'] = $content_quality;
272 }
273
274 // Save score if requested
275 if ($save_score) {
276 $user_id = get_current_user_id();
277 $score_id = $this->calculator->save_score($post_id, $user_id, $score_data);
278
279 if ($score_id) {
280 $score_data['score_id'] = $score_id;
281 }
282 }
283
284 return new WP_REST_Response([
285 'success' => true,
286 'data' => $score_data,
287 ], 200);
288
289 } catch (\Exception $e) {
290 return new WP_Error(
291 'calculation_failed',
292 'Failed to calculate SEO score: ' . $e->getMessage(),
293 ['status' => 500]
294 );
295 }
296 }
297
298 /**
299 * Get existing SEO score for a post
300 *
301 * @param WP_REST_Request $request Request object
302 * @return WP_REST_Response|WP_Error Response object
303 */
304 public function get_existing_score(WP_REST_Request $request) {
305 try {
306 $post_id = $request->get_param('post_id');
307
308 // Get existing score data from database
309 $existing_data = $this->calculator->get_existing_score_data($post_id);
310
311 if ($existing_data) {
312 return new WP_REST_Response([
313 'success' => true,
314 'data' => $existing_data,
315 'message' => __('Existing SEO score retrieved successfully', 'thinkrank')
316 ], 200);
317 } else {
318 return new WP_REST_Response([
319 'success' => false,
320 'data' => null,
321 'message' => __('No existing SEO analysis found', 'thinkrank')
322 ], 200); // 200 because it's not an error, just no data
323 }
324
325 } catch (\Exception $e) {
326 return new WP_Error('get_score_error', $e->getMessage(), ['status' => 500]);
327 }
328 }
329
330 /**
331 * Get score history for a post
332 *
333 * @param WP_REST_Request $request Request object
334 * @return WP_REST_Response|WP_Error Response object
335 */
336 public function get_score_history(WP_REST_Request $request) {
337 try {
338 $post_id = $request->get_param('post_id');
339 $limit = $request->get_param('limit') ?? 10;
340
341 $history = $this->calculator->get_score_history($post_id, $limit);
342
343 return new WP_REST_Response([
344 'success' => true,
345 'data' => $history,
346 ], 200);
347
348 } catch (\Exception $e) {
349 return new WP_Error(
350 'history_failed',
351 'Failed to retrieve score history: ' . $e->getMessage(),
352 ['status' => 500]
353 );
354 }
355 }
356
357 /**
358 * Get latest score for a post
359 *
360 * @param WP_REST_Request $request Request object
361 * @return WP_REST_Response|WP_Error Response object
362 */
363 public function get_latest_score(WP_REST_Request $request) {
364 try {
365 $post_id = $request->get_param('post_id');
366
367 $latest_score = $this->calculator->get_latest_score($post_id);
368
369 return new WP_REST_Response([
370 'success' => true,
371 'data' => $latest_score,
372 ], 200);
373
374 } catch (\Exception $e) {
375 return new WP_Error(
376 'latest_failed',
377 'Failed to retrieve latest score: ' . $e->getMessage(),
378 ['status' => 500]
379 );
380 }
381 }
382
383 /**
384 * Check permissions for API access
385 *
386 * @param WP_REST_Request $request Request object
387 * @return bool True if user has permission
388 */
389 public function check_permissions(WP_REST_Request $request): bool {
390 // Check if user is logged in
391 if (!is_user_logged_in()) {
392 return false;
393 }
394
395 // Check if user can edit posts
396 if (!current_user_can('edit_posts')) {
397 return false;
398 }
399
400 // For specific post operations, check if user can edit the specific post
401 $post_id = $request->get_param('post_id');
402 if ($post_id && !current_user_can('edit_post', $post_id)) {
403 return false;
404 }
405
406 return true;
407 }
408
409 /**
410 * Validate post ID parameter
411 *
412 * @param mixed $value Parameter value
413 * @param WP_REST_Request $request Request object
414 * @param string $param Parameter name
415 * @return bool True if valid
416 */
417 public function validate_post_id($value, WP_REST_Request $request, string $param): bool {
418 if (!is_numeric($value) || $value <= 0) {
419 return false;
420 }
421
422 $post = get_post((int) $value);
423 return $post !== null;
424 }
425 }
426