PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.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-analytics-endpoint.php

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

1,078 lines 39.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * SEO Analytics API Endpoints Class
5 *
6 * REST API endpoints for SEO analytics data collection, Google API integration,
7 * and AI-powered insights generation. Provides comprehensive API access to
8 * Analytics Manager functionality with proper authentication, validation,
9 * and error handling.
10 *
11 * @package ThinkRank
12 * @subpackage API
13 * @since 1.0.0
14 */
15
16 declare(strict_types=1);
17
18 namespace ThinkRank\API;
19
20 use ThinkRank\SEO\Analytics_Manager;
21 use ThinkRank\API\Traits\API_Cache;
22 use WP_REST_Controller;
23 use WP_REST_Request;
24 use WP_REST_Response;
25 use WP_Error;
26
27 // Prevent direct access
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 // Load API Cache trait
33 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-api-cache.php';
34
35 /**
36 * SEO Analytics API Endpoints Class
37 *
38 * Provides REST API endpoints for SEO analytics operations including
39 * Google API integration, dashboard data retrieval, SEO opportunities
40 * analysis, and connection management with proper authentication and validation.
41 *
42 * @since 1.0.0
43 */
44 class SEO_Analytics_Endpoint extends WP_REST_Controller {
45
46 use API_Cache;
47
48 /**
49 * Analytics Manager instance
50 *
51 * @since 1.0.0
52 * @var Analytics_Manager
53 */
54 private Analytics_Manager $analytics_manager;
55
56 /**
57 * API namespace
58 *
59 * @since 1.0.0
60 * @var string
61 */
62 protected $namespace = 'thinkrank/v1';
63
64 /**
65 * API resource base
66 *
67 * @since 1.0.0
68 * @var string
69 */
70 protected $rest_base = 'seo-analytics';
71
72 /**
73 * Constructor
74 *
75 * @since 1.0.0
76 * @param Analytics_Manager|null $analytics_manager Analytics manager instance
77 */
78 public function __construct(?Analytics_Manager $analytics_manager = null) {
79 $this->analytics_manager = $analytics_manager ?? new Analytics_Manager();
80
81 // Configure response caching for the live Search Console passthrough
82 // endpoints (search-totals, search-daily, branded, countries).
83 $this->set_cache_prefix('thinkrank_seo_analytics_');
84 $this->set_cache_duration(3 * HOUR_IN_SECONDS); // 3 hours
85 }
86
87 /**
88 * Register API routes
89 * Following ThinkRank endpoint registration patterns
90 *
91 * @since 1.0.0
92 */
93 public function register_routes(): void {
94 // Test Google API connections
95 register_rest_route(
96 $this->namespace,
97 '/' . $this->rest_base . '/test-connections',
98 [
99 [
100 'methods' => 'GET',
101 'callback' => [$this, 'test_connections'],
102 'permission_callback' => [$this, 'check_permissions'],
103 ]
104 ]
105 );
106
107 // Get dashboard data
108 register_rest_route(
109 $this->namespace,
110 '/' . $this->rest_base . '/dashboard',
111 [
112 [
113 'methods' => 'GET',
114 'callback' => [$this, 'get_dashboard_data'],
115 'permission_callback' => [$this, 'check_data_permissions'],
116 'args' => $this->get_dashboard_args()
117 ]
118 ]
119 );
120
121 // Get SEO opportunities
122 register_rest_route(
123 $this->namespace,
124 '/' . $this->rest_base . '/opportunities',
125 [
126 [
127 'methods' => 'GET',
128 'callback' => [$this, 'get_seo_opportunities'],
129 'permission_callback' => [$this, 'check_data_permissions'],
130 'args' => $this->get_opportunities_args()
131 ]
132 ]
133 );
134
135 // Setup Search Console verification
136 register_rest_route(
137 $this->namespace,
138 '/' . $this->rest_base . '/setup/search-console',
139 [
140 [
141 'methods' => 'POST',
142 'callback' => [$this, 'setup_search_console'],
143 'permission_callback' => [$this, 'check_permissions'],
144 'args' => $this->get_setup_args()
145 ]
146 ]
147 );
148
149 // Get indexing status
150 register_rest_route(
151 $this->namespace,
152 '/' . $this->rest_base . '/indexing-status',
153 [
154 [
155 'methods' => 'GET',
156 'callback' => [$this, 'get_indexing_status'],
157 'permission_callback' => [$this, 'check_permissions'],
158 ]
159 ]
160 );
161
162 // Refresh cached data
163 register_rest_route(
164 $this->namespace,
165 '/' . $this->rest_base . '/refresh',
166 [
167 [
168 'methods' => 'POST',
169 'callback' => [$this, 'refresh_data'],
170 'permission_callback' => [$this, 'check_permissions'],
171 ]
172 ]
173 );
174
175 // Get client status (for debugging)
176 register_rest_route(
177 $this->namespace,
178 '/' . $this->rest_base . '/status',
179 [
180 [
181 'methods' => 'GET',
182 'callback' => [$this, 'get_client_status'],
183 'permission_callback' => [$this, 'check_permissions'],
184 ]
185 ]
186 );
187
188 // ========================================
189 // SEO Intelligence Enhancement Endpoints
190 // ========================================
191
192 // Get intelligent dashboard data with trends and insights
193 register_rest_route(
194 $this->namespace,
195 '/' . $this->rest_base . '/intelligent-dashboard',
196 [
197 [
198 'methods' => 'GET',
199 'callback' => [$this, 'get_intelligent_dashboard'],
200 'permission_callback' => [$this, 'check_data_permissions'],
201 'args' => $this->get_dashboard_args()
202 ]
203 ]
204 );
205
206 // Get intelligent SEO opportunities with prioritization
207 register_rest_route(
208 $this->namespace,
209 '/' . $this->rest_base . '/intelligent-opportunities',
210 [
211 [
212 'methods' => 'GET',
213 'callback' => [$this, 'get_intelligent_opportunities'],
214 'permission_callback' => [$this, 'check_data_permissions'],
215 'args' => $this->get_opportunities_args()
216 ]
217 ]
218 );
219
220 // Get SEO insights
221 register_rest_route(
222 $this->namespace,
223 '/' . $this->rest_base . '/insights',
224 [
225 [
226 'methods' => 'GET',
227 'callback' => [$this, 'get_seo_insights'],
228 'permission_callback' => [$this, 'check_data_permissions'],
229 'args' => $this->get_dashboard_args()
230 ]
231 ]
232 );
233
234 // Get Search Console totals for custom date range
235 register_rest_route(
236 $this->namespace,
237 '/' . $this->rest_base . '/search-totals',
238 [
239 [
240 'methods' => 'GET',
241 'callback' => [$this, 'get_search_totals'],
242 'permission_callback' => [$this, 'check_data_permissions'],
243 'args' => [
244 'start_date' => [
245 'required' => true,
246 'type' => 'string',
247 'sanitize_callback' => 'sanitize_text_field',
248 'description' => 'Start date (Y-m-d)',
249 ],
250 'end_date' => [
251 'required' => true,
252 'type' => 'string',
253 'sanitize_callback' => 'sanitize_text_field',
254 'description' => 'End date (Y-m-d)',
255 ],
256 ],
257 ]
258 ]
259 );
260
261 // Get daily Search Console data (by date dimension) for chart rendering
262 register_rest_route(
263 $this->namespace,
264 '/' . $this->rest_base . '/search-daily',
265 [
266 [
267 'methods' => 'GET',
268 'callback' => [$this, 'get_search_daily'],
269 'permission_callback' => [$this, 'check_data_permissions'],
270 'args' => [
271 'date_range' => [
272 'required' => false,
273 'type' => 'string',
274 'default' => '30d',
275 'sanitize_callback' => 'sanitize_text_field',
276 'description' => 'Date range: 7d, 30d, or 90d',
277 ],
278 ],
279 ]
280 ]
281 );
282
283 // Get branded vs non-branded breakdown from Search Console
284 register_rest_route(
285 $this->namespace,
286 '/' . $this->rest_base . '/branded',
287 [
288 [
289 'methods' => 'GET',
290 'callback' => [$this, 'get_branded'],
291 'permission_callback' => [$this, 'check_data_permissions'],
292 'args' => [
293 'date_range' => [
294 'required' => false,
295 'type' => 'string',
296 'default' => '30d',
297 'sanitize_callback' => 'sanitize_text_field',
298 ],
299 'brand_name' => [
300 'required' => false,
301 'type' => 'string',
302 'default' => '',
303 'sanitize_callback' => 'sanitize_text_field',
304 ],
305 ],
306 ]
307 ]
308 );
309
310 // Get top countries from Search Console
311 register_rest_route(
312 $this->namespace,
313 '/' . $this->rest_base . '/countries',
314 [
315 [
316 'methods' => 'GET',
317 'callback' => [$this, 'get_countries'],
318 'permission_callback' => [$this, 'check_data_permissions'],
319 'args' => [
320 'date_range' => [
321 'required' => false,
322 'type' => 'string',
323 'default' => '30d',
324 'sanitize_callback' => 'sanitize_text_field',
325 'description' => 'Date range: 7d, 30d, or 90d',
326 ],
327 ],
328 ]
329 ]
330 );
331 }
332
333 /**
334 * Test Google API connections
335 * Following ThinkRank response patterns
336 *
337 * @param WP_REST_Request $request Request object
338 * @return WP_REST_Response|WP_Error Response object
339 */
340 public function test_connections(WP_REST_Request $request) {
341 try {
342 $connection_results = $this->analytics_manager->test_connections();
343
344 return new WP_REST_Response([
345 'success' => true,
346 'data' => $connection_results,
347 'message' => 'Connection tests completed'
348 ], 200);
349 } catch (\Exception $e) {
350 return new WP_Error(
351 'connection_test_failed',
352 'Connection test failed: ' . $e->getMessage(),
353 ['status' => 500]
354 );
355 }
356 }
357
358 /**
359 * Get analytics dashboard data
360 *
361 * @param WP_REST_Request $request Request object
362 * @return WP_REST_Response|WP_Error Response object
363 */
364 public function get_dashboard_data(WP_REST_Request $request) {
365 try {
366 $date_range = $request->get_param('date_range');
367 $dashboard_data = $this->analytics_manager->get_dashboard_data($date_range);
368
369 return new WP_REST_Response([
370 'success' => true,
371 'data' => $dashboard_data,
372 'message' => 'Dashboard data retrieved successfully'
373 ], 200);
374 } catch (\Exception $e) {
375 return new WP_Error(
376 'dashboard_data_failed',
377 'Failed to retrieve dashboard data: ' . $e->getMessage(),
378 ['status' => 500]
379 );
380 }
381 }
382
383 /**
384 * Get SEO opportunities
385 *
386 * @param WP_REST_Request $request Request object
387 * @return WP_REST_Response|WP_Error Response object
388 */
389 public function get_seo_opportunities(WP_REST_Request $request) {
390 try {
391 $date_range = $request->get_param('date_range');
392 $opportunities = $this->analytics_manager->get_seo_opportunities($date_range);
393
394 return new WP_REST_Response([
395 'success' => true,
396 'data' => $opportunities,
397 'message' => 'SEO opportunities retrieved successfully'
398 ], 200);
399 } catch (\Exception $e) {
400 return new WP_Error(
401 'opportunities_failed',
402 'Failed to retrieve SEO opportunities: ' . $e->getMessage(),
403 ['status' => 500]
404 );
405 }
406 }
407
408 /**
409 * Setup Search Console verification
410 *
411 * @param WP_REST_Request $request Request object
412 * @return WP_REST_Response|WP_Error Response object
413 */
414 public function setup_search_console(WP_REST_Request $request) {
415 try {
416 $site_url = $request->get_param('site_url');
417 $setup_result = $this->analytics_manager->setup_search_console_verification($site_url);
418
419 return new WP_REST_Response([
420 'success' => $setup_result['success'],
421 'data' => $setup_result,
422 'message' => $setup_result['message']
423 ], $setup_result['success'] ? 200 : 400);
424 } catch (\Exception $e) {
425 return new WP_Error(
426 'setup_failed',
427 'Search Console setup failed: ' . $e->getMessage(),
428 ['status' => 500]
429 );
430 }
431 }
432
433 /**
434 * Get indexing status
435 *
436 * @param WP_REST_Request $request Request object
437 * @return WP_REST_Response|WP_Error Response object
438 */
439 public function get_indexing_status(WP_REST_Request $request) {
440 try {
441 $indexing_status = $this->analytics_manager->get_indexing_status();
442
443 return new WP_REST_Response([
444 'success' => true,
445 'data' => $indexing_status,
446 'message' => 'Indexing status retrieved successfully'
447 ], 200);
448 } catch (\Exception $e) {
449 return new WP_Error(
450 'indexing_status_failed',
451 'Failed to retrieve indexing status: ' . $e->getMessage(),
452 ['status' => 500]
453 );
454 }
455 }
456
457 /**
458 * Refresh cached analytics data
459 *
460 * @param WP_REST_Request $request Request object
461 * @return WP_REST_Response|WP_Error Response object
462 */
463 public function refresh_data(WP_REST_Request $request) {
464 try {
465 $refresh_result = $this->analytics_manager->refresh_data();
466
467 // Bust the cached Search Console passthrough responses (search-totals,
468 // search-daily, branded, countries) so an explicit refresh re-fetches.
469 $this->invalidate_cache_pattern($this->cache_prefix . '*');
470
471 return new WP_REST_Response([
472 'success' => $refresh_result['success'],
473 'data' => $refresh_result,
474 'message' => $refresh_result['message']
475 ], 200);
476 } catch (\Exception $e) {
477 return new WP_Error(
478 'refresh_failed',
479 'Failed to refresh data: ' . $e->getMessage(),
480 ['status' => 500]
481 );
482 }
483 }
484
485 /**
486 * Get client status for debugging
487 *
488 * @param WP_REST_Request $request Request object
489 * @return WP_REST_Response|WP_Error Response object
490 */
491 public function get_client_status(WP_REST_Request $request) {
492 try {
493 $client_status = $this->analytics_manager->get_client_status();
494
495 return new WP_REST_Response([
496 'success' => true,
497 'data' => $client_status,
498 'message' => 'Client status retrieved successfully'
499 ], 200);
500 } catch (\Exception $e) {
501 return new WP_Error(
502 'status_failed',
503 'Failed to retrieve client status: ' . $e->getMessage(),
504 ['status' => 500]
505 );
506 }
507 }
508
509 /**
510 * Get dashboard endpoint arguments
511 * Following ThinkRank argument validation patterns
512 *
513 * @return array Endpoint arguments
514 */
515 private function get_dashboard_args(): array {
516 return [
517 'date_range' => [
518 'type' => 'string',
519 'default' => '30d',
520 'enum' => ['7d', '30d', '90d'],
521 'sanitize_callback' => 'sanitize_key',
522 'description' => 'Date range for analytics data'
523 ]
524 ];
525 }
526
527 /**
528 * Get opportunities endpoint arguments
529 *
530 * @return array Endpoint arguments
531 */
532 private function get_opportunities_args(): array {
533 return [
534 'date_range' => [
535 'type' => 'string',
536 'default' => '30d',
537 'enum' => ['7d', '30d', '90d'],
538 'sanitize_callback' => 'sanitize_key',
539 'description' => 'Date range for opportunities analysis'
540 ]
541 ];
542 }
543
544 /**
545 * Get setup endpoint arguments
546 *
547 * @return array Endpoint arguments
548 */
549 private function get_setup_args(): array {
550 return [
551 'site_url' => [
552 'required' => true,
553 'type' => 'string',
554 'sanitize_callback' => 'esc_url_raw',
555 'validate_callback' => [$this, 'validate_site_url'],
556 'description' => 'Site URL to verify in Search Console'
557 ]
558 ];
559 }
560
561 /**
562 * Validate site URL parameter
563 * Following ThinkRank validation patterns
564 *
565 * @param string $site_url Site URL to validate
566 * @return bool|WP_Error Validation result
567 */
568 public function validate_site_url($site_url) {
569 // Not a `string` type hint: this is a validate_callback, so it runs on
570 // the raw pre-sanitize parameter. `?site_url[]=x` handed it an array
571 // and PHP raised an uncaught TypeError — a 500 where the API owes the
572 // caller a 400 (#394).
573 if (!is_string($site_url)) {
574 return new WP_Error(
575 'invalid_site_url',
576 'Site URL must be a string',
577 ['status' => 400]
578 );
579 }
580
581 if (empty($site_url)) {
582 return new WP_Error(
583 'invalid_site_url',
584 'Site URL is required',
585 ['status' => 400]
586 );
587 }
588
589 if (!filter_var($site_url, FILTER_VALIDATE_URL)) {
590 return new WP_Error(
591 'invalid_site_url',
592 'Site URL must be a valid URL',
593 ['status' => 400]
594 );
595 }
596
597 return true;
598 }
599
600 /**
601 * Get Search Console totals for a custom date range
602 *
603 * @param WP_REST_Request $request Request object
604 * @return WP_REST_Response|WP_Error Response object
605 */
606 public function get_search_totals(WP_REST_Request $request) {
607 try {
608 $start_date = $request->get_param('start_date');
609 $end_date = $request->get_param('end_date');
610
611 // Validate date format and actual calendar validity
612 $start_dt = \DateTime::createFromFormat('Y-m-d', $start_date);
613 $end_dt = \DateTime::createFromFormat('Y-m-d', $end_date);
614 if (
615 !$start_dt || $start_dt->format('Y-m-d') !== $start_date ||
616 !$end_dt || $end_dt->format('Y-m-d') !== $end_date
617 ) {
618 return new WP_Error('invalid_dates', 'Dates must be valid calendar dates in Y-m-d format', ['status' => 400]);
619 }
620 if ($start_dt > $end_dt) {
621 return new WP_Error('invalid_dates', 'start_date must not be after end_date', ['status' => 400]);
622 }
623
624 // Use Analytics Manager to access the initialized client with decrypted credentials
625 $context = $this->resolve_search_console();
626 if (is_wp_error($context)) {
627 return $context;
628 }
629 [$search_console, $site_url] = $context;
630
631 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
632 $response = $this->cached_response(
633 'search_totals',
634 function () use ($search_console, $site_url, $start_date, $end_date) {
635 return [
636 'success' => true,
637 'data' => $search_console->get_search_totals_by_dates($site_url, $start_date, $end_date),
638 'message' => 'Search totals retrieved',
639 ];
640 },
641 ['start_date' => $start_date, 'end_date' => $end_date]
642 );
643
644 return new WP_REST_Response($response, 200);
645 } catch (\Exception $e) {
646 return $this->google_error_to_wp_error($e, 'search_totals_failed');
647 }
648 }
649
650 /**
651 * Get daily Search Console data grouped by date for chart rendering.
652 *
653 * Returns rows sorted ascending by date, each containing:
654 * clicks, impressions, ctr (as %), position.
655 *
656 * @param WP_REST_Request $request Request object
657 * @return WP_REST_Response|WP_Error Response object
658 */
659 public function get_search_daily(WP_REST_Request $request) {
660 try {
661 $date_range = $request->get_param('date_range') ?: '30d';
662 $days = (int) preg_replace('/[^0-9]/', '', $date_range);
663 if ($days <= 0 || $days > 90) {
664 $days = 30;
665 }
666
667 // Window = exactly $days back from today (inclusive of today).
668 // 7d → today-6 ... today
669 // 30d → today-29 ... today
670 // 90d → today-89 ... today
671 $end_date = gmdate('Y-m-d');
672 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days'));
673
674 $context = $this->resolve_search_console();
675 if (is_wp_error($context)) {
676 return $context;
677 }
678 [$search_console, $site_url] = $context;
679
680 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
681 $response = $this->cached_response(
682 'search_daily',
683 function () use ($search_console, $site_url, $start_date, $end_date, $days) {
684 $raw_rows = $search_console->get_search_performance_by_dates(
685 $site_url,
686 $start_date,
687 $end_date,
688 $days + 5,
689 ['date']
690 );
691
692 // Index GSC rows by date so we can pad missing days (GSC's lag means
693 // the most recent few days often have no data yet).
694 $by_date = [];
695 foreach ($raw_rows as $row) {
696 $date = $row['keys'][0] ?? '';
697 if (!$date) {
698 continue;
699 }
700 $by_date[$date] = [
701 'clicks' => (int) ($row['clicks'] ?? 0),
702 'impressions' => (int) ($row['impressions'] ?? 0),
703 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
704 'position' => round($row['position'] ?? 0, 1),
705 ];
706 }
707
708 // Build a contiguous N-day series from $start_date → $end_date.
709 // Days GSC has no data for (today minus 2-4 days, typically) come
710 // through as zeros so the chart x-axis always spans the full window.
711 $rows = [];
712 $cursor = strtotime($start_date);
713 $end_ts = strtotime($end_date);
714 while ($cursor <= $end_ts) {
715 $date = gmdate('Y-m-d', $cursor);
716 $rows[] = array_merge(
717 ['date' => $date],
718 $by_date[$date] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0]
719 );
720 $cursor = strtotime('+1 day', $cursor);
721 }
722
723 return [
724 'success' => true,
725 'data' => [
726 'rows' => $rows,
727 'start_date' => $start_date,
728 'end_date' => $end_date,
729 ],
730 'message' => 'Daily search data retrieved',
731 ];
732 },
733 ['date_range' => $date_range, 'start_date' => $start_date, 'end_date' => $end_date]
734 );
735
736 return new WP_REST_Response($response, 200);
737 } catch (\Exception $e) {
738 return $this->google_error_to_wp_error($e, 'search_daily_failed');
739 }
740 }
741
742 /**
743 * Get branded vs non-branded query breakdown from Search Console.
744 *
745 * Accepts optional `brand_name` param (comma-separated keywords).
746 * When omitted the brand is auto-derived from the registered domain.
747 * Also returns the equivalent previous-period data so the frontend can
748 * compute trend arrows without a second round-trip.
749 *
750 * @param WP_REST_Request $request Request object
751 * @return WP_REST_Response|WP_Error
752 */
753 public function get_branded(WP_REST_Request $request) {
754 try {
755 $date_range = $request->get_param('date_range') ?: '30d';
756 $brand_name = $request->get_param('brand_name') ?: '';
757
758 $context = $this->resolve_search_console();
759 if (is_wp_error($context)) {
760 return $context;
761 }
762 [$search_console, $site_url] = $context;
763
764 // Cache the (double) live GSC call (3h TTL, site-wide) keyed by
765 // date range + brand terms.
766 $response = $this->cached_response(
767 'branded',
768 function () use ($search_console, $site_url, $date_range, $brand_name) {
769 return [
770 'success' => true,
771 'data' => $search_console->get_branded_performance($site_url, $date_range, $brand_name),
772 'message' => 'Branded data retrieved',
773 ];
774 },
775 ['date_range' => $date_range, 'brand_name' => $brand_name]
776 );
777
778 return new WP_REST_Response($response, 200);
779 } catch (\Exception $e) {
780 return $this->google_error_to_wp_error($e, 'branded_failed');
781 }
782 }
783
784 /**
785 * Get top countries from Search Console (country dimension).
786 *
787 * Returns up to 10 countries sorted by clicks descending, each with
788 * clicks, impressions, ctr, position, and a percentage share of total clicks.
789 *
790 * @param WP_REST_Request $request Request object
791 * @return WP_REST_Response|WP_Error Response object
792 */
793 public function get_countries(WP_REST_Request $request) {
794 try {
795 $date_range = $request->get_param('date_range') ?: '30d';
796
797 $context = $this->resolve_search_console();
798 if (is_wp_error($context)) {
799 return $context;
800 }
801 [$search_console, $site_url] = $context;
802
803 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
804 $response = $this->cached_response(
805 'countries',
806 function () use ($search_console, $site_url, $date_range) {
807 return [
808 'success' => true,
809 'data' => $search_console->get_country_performance($site_url, $date_range),
810 'message' => 'Country data retrieved',
811 ];
812 },
813 ['date_range' => $date_range]
814 );
815
816 return new WP_REST_Response($response, 200);
817 } catch (\Exception $e) {
818 return $this->google_error_to_wp_error($e, 'countries_failed');
819 }
820 }
821
822 /**
823 * Resolve the Search Console client + property URL for the live GSC routes.
824 *
825 * Both failure modes are configuration problems the site owner can fix, so
826 * they return an actionable error instead of letting the request reach
827 * Google and bounce back as raw API text — an unselected property, for
828 * example, otherwise surfaces as
829 * "Google API error (400): 'http://' is not a valid Search Console site URL".
830 *
831 * @since 1.0.0
832 * @return array{0: \ThinkRank\Integrations\Google_Search_Console_Client, 1: string}|WP_Error
833 */
834 private function resolve_search_console() {
835 $client = $this->analytics_manager->get_search_console_client();
836
837 if (!$client) {
838 return new WP_Error(
839 'google_not_connected',
840 __('Google Search Console is not connected yet. Connect your Google account to see search data here.', 'thinkrank'),
841 ['status' => 400, 'reason' => 'not_connected']
842 );
843 }
844
845 $site_url = trim((string) $this->analytics_manager->get_property_url());
846
847 // Accept only the two formats Search Console recognises: a URL-prefix
848 // property (https://example.com/) or a domain property
849 // (sc-domain:example.com). Anything else — most often an empty setting —
850 // means no verified property has been picked yet.
851 if (!preg_match('#^(sc-domain:\S+|https?://\S+)$#i', $site_url)) {
852 return new WP_Error(
853 'no_search_console_property',
854 __('No Search Console property is selected for this site. Choose your verified property to start loading search data.', 'thinkrank'),
855 ['status' => 400, 'reason' => 'no_property']
856 );
857 }
858
859 return [$client, $site_url];
860 }
861
862 /**
863 * Turn a Google API exception into an error a site owner can act on.
864 *
865 * Google's own wording ("'http://' is not a valid Search Console site URL",
866 * bare 401/403s) tells an admin nothing about what to fix, so map the common
867 * statuses to plain-language messages plus a `reason` the UI turns into the
868 * matching call to action. The raw text is preserved in `details` for
869 * debugging — these routes are already admin-gated.
870 *
871 * @since 1.0.0
872 * @param \Exception $e Exception thrown by the Google client.
873 * @param string $code WP_Error code for the failing route.
874 * @return WP_Error Actionable error.
875 */
876 private function google_error_to_wp_error(\Exception $e, string $code): WP_Error {
877 $status = (int) $e->getCode();
878 // The Google client escapes the API's message before wrapping it in the
879 // exception, so decode it back for display — the UI renders `details` as
880 // plain text, where entities would show up literally ("&#039;").
881 $raw = html_entity_decode($e->getMessage(), ENT_QUOTES, 'UTF-8');
882
883 if (stripos($raw, 'valid Search Console site URL') !== false || $status === 404) {
884 $reason = 'no_property';
885 $message = __('The Search Console property for this site is missing or no longer valid. Select your verified property again to restore search data.', 'thinkrank');
886 $http_status = 400;
887 } elseif ($status === 401) {
888 $reason = 'reconnect';
889 $message = __('Your Google connection has expired. Reconnect your Google account to load Search Console data.', 'thinkrank');
890 $http_status = 401;
891 } elseif ($status === 403) {
892 $reason = 'permission';
893 $message = __('Your Google account does not have access to this Search Console property. Verify ownership in Search Console, or select a property you own.', 'thinkrank');
894 $http_status = 403;
895 } elseif ($status === 429) {
896 $reason = 'quota';
897 $message = __('Google is rate limiting requests right now. Search data will load again shortly.', 'thinkrank');
898 $http_status = 429;
899 } elseif ($status >= 500) {
900 $reason = 'google_down';
901 $message = __('Google Search Console is temporarily unavailable. Please try again in a few minutes.', 'thinkrank');
902 $http_status = 502;
903 } else {
904 $reason = 'unknown';
905 $message = __('Search Console data could not be loaded right now. Please try again.', 'thinkrank');
906 $http_status = 502;
907 }
908
909 return new WP_Error(
910 $code,
911 $message,
912 [
913 'status' => $http_status,
914 'reason' => $reason,
915 'details' => $raw,
916 ]
917 );
918 }
919
920 /**
921 * Check permissions for API access
922 * Following ThinkRank permission patterns
923 *
924 * @return bool Permission status
925 */
926 public function check_permissions(): bool {
927 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_analytics');
928 }
929
930 /**
931 * Check permissions for the Google-backed data routes
932 *
933 * On top of the capability check, requires the SEO Analytics feature
934 * toggle to be enabled. Without this guard a disabled feature would
935 * still hit the Google APIs and surface raw errors (e.g. 401s when no
936 * Google account is connected). Settings read/write routes are not
937 * gated so the feature can always be (re-)enabled.
938 *
939 * @return bool|WP_Error True when allowed, false or WP_Error otherwise
940 */
941 public function check_data_permissions() {
942 if (!$this->check_permissions()) {
943 return false;
944 }
945
946 if (!\ThinkRank\Core\Settings::instance()->get('seo_analytics_enabled', false)) {
947 return new WP_Error(
948 'seo_analytics_disabled',
949 __('SEO Analytics is disabled. Enable it in the SEO Analytics settings to load data.', 'thinkrank'),
950 ['status' => 403]
951 );
952 }
953
954 return true;
955 }
956
957 // ========================================
958 // SEO Intelligence Enhancement Endpoints
959 // ========================================
960
961 /**
962 * Get intelligent dashboard data with trends and insights
963 *
964 * @param WP_REST_Request $request Request object
965 * @return WP_REST_Response|WP_Error Response object
966 */
967 public function get_intelligent_dashboard(WP_REST_Request $request) {
968 try {
969 $date_range = $request->get_param('date_range');
970
971 // Cache the intelligence computation (trend analysis + generators are
972 // expensive) so the AI-insights panel doesn't recompute every load.
973 $response = $this->cached_response(
974 'intelligent_dashboard',
975 function () use ($date_range) {
976 $intelligent_data = $this->analytics_manager->get_intelligent_dashboard_data($date_range);
977
978 return [
979 'success' => isset($intelligent_data['success']) ? $intelligent_data['success'] : false,
980 'data' => $intelligent_data['data'] ?? null,
981 'message' => $intelligent_data['message'] ?? 'Intelligent dashboard data retrieved',
982 'timestamp' => current_time('mysql'),
983 ];
984 },
985 ['date_range' => $date_range]
986 );
987
988 // Always return 200 for successful API calls, even when no data available.
989 return new WP_REST_Response($response, 200);
990
991 } catch (\Exception $e) {
992 return new WP_Error(
993 'intelligent_dashboard_error',
994 'Failed to retrieve intelligent dashboard data: ' . $e->getMessage(),
995 ['status' => 500]
996 );
997 }
998 }
999
1000 /**
1001 * Get intelligent SEO opportunities with prioritization
1002 *
1003 * @param WP_REST_Request $request Request object
1004 * @return WP_REST_Response|WP_Error Response object
1005 */
1006 public function get_intelligent_opportunities(WP_REST_Request $request) {
1007 try {
1008 $date_range = $request->get_param('date_range');
1009
1010 // Cache the opportunity detection so it doesn't recompute every load.
1011 $response = $this->cached_response(
1012 'intelligent_opportunities',
1013 function () use ($date_range) {
1014 $intelligent_opportunities = $this->analytics_manager->get_intelligent_seo_opportunities($date_range);
1015
1016 return [
1017 'success' => isset($intelligent_opportunities['success']) ? $intelligent_opportunities['success'] : false,
1018 'data' => $intelligent_opportunities['data'] ?? null,
1019 'message' => $intelligent_opportunities['message'] ?? 'Intelligent opportunities retrieved',
1020 'timestamp' => current_time('mysql'),
1021 ];
1022 },
1023 ['date_range' => $date_range]
1024 );
1025
1026 // Always return 200 for successful API calls, even when no data available.
1027 return new WP_REST_Response($response, 200);
1028
1029 } catch (\Exception $e) {
1030 return new WP_Error(
1031 'intelligent_opportunities_error',
1032 'Failed to retrieve intelligent opportunities: ' . $e->getMessage(),
1033 ['status' => 500]
1034 );
1035 }
1036 }
1037
1038 /**
1039 * Get SEO insights
1040 *
1041 * @param WP_REST_Request $request Request object
1042 * @return WP_REST_Response|WP_Error Response object
1043 */
1044 public function get_seo_insights(WP_REST_Request $request) {
1045 try {
1046 $date_range = $request->get_param('date_range');
1047
1048 // Endpoint-level cache for consistency with the other two intelligence
1049 // calls (the manager also caches insights internally).
1050 $response = $this->cached_response(
1051 'seo_insights',
1052 function () use ($date_range) {
1053 $insights = $this->analytics_manager->get_seo_insights($date_range);
1054
1055 return [
1056 'success' => isset($insights['success']) ? $insights['success'] : false,
1057 'data' => $insights['data'] ?? null,
1058 'cached' => $insights['cached'] ?? false,
1059 'message' => $insights['message'] ?? 'SEO insights retrieved',
1060 'timestamp' => current_time('mysql'),
1061 ];
1062 },
1063 ['date_range' => $date_range]
1064 );
1065
1066 // Always return 200 for successful API calls, even when no data available.
1067 return new WP_REST_Response($response, 200);
1068
1069 } catch (\Exception $e) {
1070 return new WP_Error(
1071 'seo_insights_error',
1072 'Failed to retrieve SEO insights: ' . $e->getMessage(),
1073 ['status' => 500]
1074 );
1075 }
1076 }
1077 }
1078