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

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