PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/api/class-seo-analytics-endpoint.php

874 lines 31.8 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 // Refresh cached data
150 register_rest_route(
151 $this->namespace,
152 '/' . $this->rest_base . '/refresh',
153 [
154 [
155 'methods' => 'POST',
156 'callback' => [$this, 'refresh_data'],
157 'permission_callback' => [$this, 'check_permissions'],
158 ]
159 ]
160 );
161
162 // Get client status (for debugging)
163 register_rest_route(
164 $this->namespace,
165 '/' . $this->rest_base . '/status',
166 [
167 [
168 'methods' => 'GET',
169 'callback' => [$this, 'get_client_status'],
170 'permission_callback' => [$this, 'check_permissions'],
171 ]
172 ]
173 );
174
175 // Get Search Console totals for custom date range
176 register_rest_route(
177 $this->namespace,
178 '/' . $this->rest_base . '/search-totals',
179 [
180 [
181 'methods' => 'GET',
182 'callback' => [$this, 'get_search_totals'],
183 'permission_callback' => [$this, 'check_data_permissions'],
184 'args' => [
185 'start_date' => [
186 'required' => true,
187 'type' => 'string',
188 'sanitize_callback' => 'sanitize_text_field',
189 'description' => 'Start date (Y-m-d)',
190 ],
191 'end_date' => [
192 'required' => true,
193 'type' => 'string',
194 'sanitize_callback' => 'sanitize_text_field',
195 'description' => 'End date (Y-m-d)',
196 ],
197 ],
198 ]
199 ]
200 );
201
202 // Get daily Search Console data (by date dimension) for chart rendering
203 register_rest_route(
204 $this->namespace,
205 '/' . $this->rest_base . '/search-daily',
206 [
207 [
208 'methods' => 'GET',
209 'callback' => [$this, 'get_search_daily'],
210 'permission_callback' => [$this, 'check_data_permissions'],
211 'args' => [
212 'date_range' => [
213 'required' => false,
214 'type' => 'string',
215 'default' => '30d',
216 'sanitize_callback' => 'sanitize_text_field',
217 'description' => 'Date range: 7d, 30d, or 90d',
218 ],
219 ],
220 ]
221 ]
222 );
223
224 // Get branded vs non-branded breakdown from Search Console
225 register_rest_route(
226 $this->namespace,
227 '/' . $this->rest_base . '/branded',
228 [
229 [
230 'methods' => 'GET',
231 'callback' => [$this, 'get_branded'],
232 'permission_callback' => [$this, 'check_data_permissions'],
233 'args' => [
234 'date_range' => [
235 'required' => false,
236 'type' => 'string',
237 'default' => '30d',
238 'sanitize_callback' => 'sanitize_text_field',
239 ],
240 'brand_name' => [
241 'required' => false,
242 'type' => 'string',
243 'default' => '',
244 'sanitize_callback' => 'sanitize_text_field',
245 ],
246 ],
247 ]
248 ]
249 );
250
251 // Get top countries from Search Console
252 register_rest_route(
253 $this->namespace,
254 '/' . $this->rest_base . '/countries',
255 [
256 [
257 'methods' => 'GET',
258 'callback' => [$this, 'get_countries'],
259 'permission_callback' => [$this, 'check_data_permissions'],
260 'args' => [
261 'date_range' => [
262 'required' => false,
263 'type' => 'string',
264 'default' => '30d',
265 'sanitize_callback' => 'sanitize_text_field',
266 'description' => 'Date range: 7d, 30d, or 90d',
267 ],
268 ],
269 ]
270 ]
271 );
272 }
273
274 /**
275 * Test Google API connections
276 * Following ThinkRank response patterns
277 *
278 * @param WP_REST_Request $request Request object
279 * @return WP_REST_Response|WP_Error Response object
280 */
281 public function test_connections(WP_REST_Request $request) {
282 try {
283 $connection_results = $this->analytics_manager->test_connections();
284
285 return new WP_REST_Response([
286 'success' => true,
287 'data' => $connection_results,
288 'message' => 'Connection tests completed'
289 ], 200);
290 } catch (\Exception $e) {
291 return new WP_Error(
292 'connection_test_failed',
293 'Connection test failed: ' . $e->getMessage(),
294 ['status' => 500]
295 );
296 }
297 }
298
299 /**
300 * Get analytics dashboard data
301 *
302 * @param WP_REST_Request $request Request object
303 * @return WP_REST_Response|WP_Error Response object
304 */
305 public function get_dashboard_data(WP_REST_Request $request) {
306 try {
307 $date_range = $request->get_param('date_range');
308 $dashboard_data = $this->analytics_manager->get_dashboard_data($date_range);
309
310 return new WP_REST_Response([
311 'success' => true,
312 'data' => $dashboard_data,
313 'message' => 'Dashboard data retrieved successfully'
314 ], 200);
315 } catch (\Exception $e) {
316 return new WP_Error(
317 'dashboard_data_failed',
318 'Failed to retrieve dashboard data: ' . $e->getMessage(),
319 ['status' => 500]
320 );
321 }
322 }
323
324 /**
325 * Get SEO opportunities
326 *
327 * @param WP_REST_Request $request Request object
328 * @return WP_REST_Response|WP_Error Response object
329 */
330 public function get_seo_opportunities(WP_REST_Request $request) {
331 try {
332 $date_range = $request->get_param('date_range');
333 $opportunities = $this->analytics_manager->get_seo_opportunities($date_range);
334
335 return new WP_REST_Response([
336 'success' => true,
337 'data' => $opportunities,
338 'message' => 'SEO opportunities retrieved successfully'
339 ], 200);
340 } catch (\Exception $e) {
341 return new WP_Error(
342 'opportunities_failed',
343 'Failed to retrieve SEO opportunities: ' . $e->getMessage(),
344 ['status' => 500]
345 );
346 }
347 }
348
349 /**
350 * Setup Search Console verification
351 *
352 * @param WP_REST_Request $request Request object
353 * @return WP_REST_Response|WP_Error Response object
354 */
355 public function setup_search_console(WP_REST_Request $request) {
356 try {
357 $site_url = $request->get_param('site_url');
358 $setup_result = $this->analytics_manager->setup_search_console_verification($site_url);
359
360 return new WP_REST_Response([
361 'success' => $setup_result['success'],
362 'data' => $setup_result,
363 'message' => $setup_result['message']
364 ], $setup_result['success'] ? 200 : 400);
365 } catch (\Exception $e) {
366 return new WP_Error(
367 'setup_failed',
368 'Search Console setup failed: ' . $e->getMessage(),
369 ['status' => 500]
370 );
371 }
372 }
373
374 /**
375 * Refresh cached analytics data
376 *
377 * @param WP_REST_Request $request Request object
378 * @return WP_REST_Response|WP_Error Response object
379 */
380 public function refresh_data(WP_REST_Request $request) {
381 try {
382 $refresh_result = $this->analytics_manager->refresh_data();
383
384 // Bust the cached Search Console passthrough responses (search-totals,
385 // search-daily, branded, countries) so an explicit refresh re-fetches.
386 $this->invalidate_cache_pattern($this->cache_prefix . '*');
387
388 return new WP_REST_Response([
389 'success' => $refresh_result['success'],
390 'data' => $refresh_result,
391 'message' => $refresh_result['message']
392 ], 200);
393 } catch (\Exception $e) {
394 return new WP_Error(
395 'refresh_failed',
396 'Failed to refresh data: ' . $e->getMessage(),
397 ['status' => 500]
398 );
399 }
400 }
401
402 /**
403 * Get client status for debugging
404 *
405 * @param WP_REST_Request $request Request object
406 * @return WP_REST_Response|WP_Error Response object
407 */
408 public function get_client_status(WP_REST_Request $request) {
409 try {
410 $client_status = $this->analytics_manager->get_client_status();
411
412 return new WP_REST_Response([
413 'success' => true,
414 'data' => $client_status,
415 'message' => 'Client status retrieved successfully'
416 ], 200);
417 } catch (\Exception $e) {
418 return new WP_Error(
419 'status_failed',
420 'Failed to retrieve client status: ' . $e->getMessage(),
421 ['status' => 500]
422 );
423 }
424 }
425
426 /**
427 * Get dashboard endpoint arguments
428 * Following ThinkRank argument validation patterns
429 *
430 * @return array Endpoint arguments
431 */
432 private function get_dashboard_args(): array {
433 return [
434 'date_range' => [
435 'type' => 'string',
436 'default' => '30d',
437 'enum' => ['7d', '30d', '90d'],
438 'sanitize_callback' => 'sanitize_key',
439 'description' => 'Date range for analytics data'
440 ]
441 ];
442 }
443
444 /**
445 * Get opportunities endpoint arguments
446 *
447 * @return array Endpoint arguments
448 */
449 private function get_opportunities_args(): array {
450 return [
451 'date_range' => [
452 'type' => 'string',
453 'default' => '30d',
454 'enum' => ['7d', '30d', '90d'],
455 'sanitize_callback' => 'sanitize_key',
456 'description' => 'Date range for opportunities analysis'
457 ]
458 ];
459 }
460
461 /**
462 * Get setup endpoint arguments
463 *
464 * @return array Endpoint arguments
465 */
466 private function get_setup_args(): array {
467 return [
468 'site_url' => [
469 'required' => true,
470 'type' => 'string',
471 'sanitize_callback' => 'esc_url_raw',
472 'validate_callback' => [$this, 'validate_site_url'],
473 'description' => 'Site URL to verify in Search Console'
474 ]
475 ];
476 }
477
478 /**
479 * Validate site URL parameter
480 * Following ThinkRank validation patterns
481 *
482 * @param string $site_url Site URL to validate
483 * @return bool|WP_Error Validation result
484 */
485 public function validate_site_url($site_url) {
486 // Not a `string` type hint: this is a validate_callback, so it runs on
487 // the raw pre-sanitize parameter. `?site_url[]=x` handed it an array
488 // and PHP raised an uncaught TypeError — a 500 where the API owes the
489 // caller a 400 (#394).
490 if (!is_string($site_url)) {
491 return new WP_Error(
492 'invalid_site_url',
493 'Site URL must be a string',
494 ['status' => 400]
495 );
496 }
497
498 if (empty($site_url)) {
499 return new WP_Error(
500 'invalid_site_url',
501 'Site URL is required',
502 ['status' => 400]
503 );
504 }
505
506 if (!filter_var($site_url, FILTER_VALIDATE_URL)) {
507 return new WP_Error(
508 'invalid_site_url',
509 'Site URL must be a valid URL',
510 ['status' => 400]
511 );
512 }
513
514 return true;
515 }
516
517 /**
518 * Get Search Console totals for a custom date range
519 *
520 * @param WP_REST_Request $request Request object
521 * @return WP_REST_Response|WP_Error Response object
522 */
523 public function get_search_totals(WP_REST_Request $request) {
524 try {
525 $start_date = $request->get_param('start_date');
526 $end_date = $request->get_param('end_date');
527
528 // Validate date format and actual calendar validity
529 $start_dt = \DateTime::createFromFormat('Y-m-d', $start_date);
530 $end_dt = \DateTime::createFromFormat('Y-m-d', $end_date);
531 if (
532 !$start_dt || $start_dt->format('Y-m-d') !== $start_date ||
533 !$end_dt || $end_dt->format('Y-m-d') !== $end_date
534 ) {
535 return new WP_Error('invalid_dates', 'Dates must be valid calendar dates in Y-m-d format', ['status' => 400]);
536 }
537 if ($start_dt > $end_dt) {
538 return new WP_Error('invalid_dates', 'start_date must not be after end_date', ['status' => 400]);
539 }
540
541 // Use Analytics Manager to access the initialized client with decrypted credentials
542 $context = $this->resolve_search_console();
543 if (is_wp_error($context)) {
544 return $context;
545 }
546 [$search_console, $site_url] = $context;
547
548 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
549 $response = $this->cached_response(
550 'search_totals',
551 function () use ($search_console, $site_url, $start_date, $end_date) {
552 return [
553 'success' => true,
554 'data' => $search_console->get_search_totals_by_dates($site_url, $start_date, $end_date),
555 'message' => 'Search totals retrieved',
556 ];
557 },
558 ['start_date' => $start_date, 'end_date' => $end_date]
559 );
560
561 return new WP_REST_Response($response, 200);
562 } catch (\Exception $e) {
563 return $this->google_error_to_wp_error($e, 'search_totals_failed');
564 }
565 }
566
567 /**
568 * Get daily Search Console data grouped by date for chart rendering.
569 *
570 * Returns rows sorted ascending by date, each containing:
571 * clicks, impressions, ctr (as %), position.
572 *
573 * @param WP_REST_Request $request Request object
574 * @return WP_REST_Response|WP_Error Response object
575 */
576 public function get_search_daily(WP_REST_Request $request) {
577 try {
578 $date_range = $request->get_param('date_range') ?: '30d';
579 $days = (int) preg_replace('/[^0-9]/', '', $date_range);
580 if ($days <= 0 || $days > 90) {
581 $days = 30;
582 }
583
584 // Window = exactly $days back from today (inclusive of today).
585 // 7d → today-6 ... today
586 // 30d → today-29 ... today
587 // 90d → today-89 ... today
588 $end_date = gmdate('Y-m-d');
589 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days'));
590
591 $context = $this->resolve_search_console();
592 if (is_wp_error($context)) {
593 return $context;
594 }
595 [$search_console, $site_url] = $context;
596
597 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
598 $response = $this->cached_response(
599 'search_daily',
600 function () use ($search_console, $site_url, $start_date, $end_date, $days) {
601 $raw_rows = $search_console->get_search_performance_by_dates(
602 $site_url,
603 $start_date,
604 $end_date,
605 $days + 5,
606 ['date']
607 );
608
609 // Index GSC rows by date so we can pad missing days (GSC's lag means
610 // the most recent few days often have no data yet).
611 $by_date = [];
612 foreach ($raw_rows as $row) {
613 $date = $row['keys'][0] ?? '';
614 if (!$date) {
615 continue;
616 }
617 $by_date[$date] = [
618 'clicks' => (int) ($row['clicks'] ?? 0),
619 'impressions' => (int) ($row['impressions'] ?? 0),
620 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
621 'position' => round($row['position'] ?? 0, 1),
622 ];
623 }
624
625 // Build a contiguous N-day series from $start_date → $end_date.
626 // Days GSC has no data for (today minus 2-4 days, typically) come
627 // through as zeros so the chart x-axis always spans the full window.
628 $rows = [];
629 $cursor = strtotime($start_date);
630 $end_ts = strtotime($end_date);
631 while ($cursor <= $end_ts) {
632 $date = gmdate('Y-m-d', $cursor);
633 $rows[] = array_merge(
634 ['date' => $date],
635 $by_date[$date] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0]
636 );
637 $cursor = strtotime('+1 day', $cursor);
638 }
639
640 return [
641 'success' => true,
642 'data' => [
643 'rows' => $rows,
644 'start_date' => $start_date,
645 'end_date' => $end_date,
646 ],
647 'message' => 'Daily search data retrieved',
648 ];
649 },
650 ['date_range' => $date_range, 'start_date' => $start_date, 'end_date' => $end_date]
651 );
652
653 return new WP_REST_Response($response, 200);
654 } catch (\Exception $e) {
655 return $this->google_error_to_wp_error($e, 'search_daily_failed');
656 }
657 }
658
659 /**
660 * Get branded vs non-branded query breakdown from Search Console.
661 *
662 * Accepts optional `brand_name` param (comma-separated keywords).
663 * When omitted the brand is auto-derived from the registered domain.
664 * Also returns the equivalent previous-period data so the frontend can
665 * compute trend arrows without a second round-trip.
666 *
667 * @param WP_REST_Request $request Request object
668 * @return WP_REST_Response|WP_Error
669 */
670 public function get_branded(WP_REST_Request $request) {
671 try {
672 $date_range = $request->get_param('date_range') ?: '30d';
673 $brand_name = $request->get_param('brand_name') ?: '';
674
675 $context = $this->resolve_search_console();
676 if (is_wp_error($context)) {
677 return $context;
678 }
679 [$search_console, $site_url] = $context;
680
681 // Cache the (double) live GSC call (3h TTL, site-wide) keyed by
682 // date range + brand terms.
683 $response = $this->cached_response(
684 'branded',
685 function () use ($search_console, $site_url, $date_range, $brand_name) {
686 return [
687 'success' => true,
688 'data' => $search_console->get_branded_performance($site_url, $date_range, $brand_name),
689 'message' => 'Branded data retrieved',
690 ];
691 },
692 ['date_range' => $date_range, 'brand_name' => $brand_name]
693 );
694
695 return new WP_REST_Response($response, 200);
696 } catch (\Exception $e) {
697 return $this->google_error_to_wp_error($e, 'branded_failed');
698 }
699 }
700
701 /**
702 * Get top countries from Search Console (country dimension).
703 *
704 * Returns up to 10 countries sorted by clicks descending, each with
705 * clicks, impressions, ctr, position, and a percentage share of total clicks.
706 *
707 * @param WP_REST_Request $request Request object
708 * @return WP_REST_Response|WP_Error Response object
709 */
710 public function get_countries(WP_REST_Request $request) {
711 try {
712 $date_range = $request->get_param('date_range') ?: '30d';
713
714 $context = $this->resolve_search_console();
715 if (is_wp_error($context)) {
716 return $context;
717 }
718 [$search_console, $site_url] = $context;
719
720 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
721 $response = $this->cached_response(
722 'countries',
723 function () use ($search_console, $site_url, $date_range) {
724 return [
725 'success' => true,
726 'data' => $search_console->get_country_performance($site_url, $date_range),
727 'message' => 'Country data retrieved',
728 ];
729 },
730 ['date_range' => $date_range]
731 );
732
733 return new WP_REST_Response($response, 200);
734 } catch (\Exception $e) {
735 return $this->google_error_to_wp_error($e, 'countries_failed');
736 }
737 }
738
739 /**
740 * Resolve the Search Console client + property URL for the live GSC routes.
741 *
742 * Both failure modes are configuration problems the site owner can fix, so
743 * they return an actionable error instead of letting the request reach
744 * Google and bounce back as raw API text — an unselected property, for
745 * example, otherwise surfaces as
746 * "Google API error (400): 'http://' is not a valid Search Console site URL".
747 *
748 * @since 1.0.0
749 * @return array{0: \ThinkRank\Integrations\Google_Search_Console_Client, 1: string}|WP_Error
750 */
751 private function resolve_search_console() {
752 $client = $this->analytics_manager->get_search_console_client();
753
754 if (!$client) {
755 return new WP_Error(
756 'google_not_connected',
757 __('Google Search Console is not connected yet. Connect your Google account to see search data here.', 'thinkrank'),
758 ['status' => 400, 'reason' => 'not_connected']
759 );
760 }
761
762 $site_url = trim((string) $this->analytics_manager->get_property_url());
763
764 // Accept only the two formats Search Console recognises: a URL-prefix
765 // property (https://example.com/) or a domain property
766 // (sc-domain:example.com). Anything else — most often an empty setting —
767 // means no verified property has been picked yet.
768 if (!preg_match('#^(sc-domain:\S+|https?://\S+)$#i', $site_url)) {
769 return new WP_Error(
770 'no_search_console_property',
771 __('No Search Console property is selected for this site. Choose your verified property to start loading search data.', 'thinkrank'),
772 ['status' => 400, 'reason' => 'no_property']
773 );
774 }
775
776 return [$client, $site_url];
777 }
778
779 /**
780 * Turn a Google API exception into an error a site owner can act on.
781 *
782 * Google's own wording ("'http://' is not a valid Search Console site URL",
783 * bare 401/403s) tells an admin nothing about what to fix, so map the common
784 * statuses to plain-language messages plus a `reason` the UI turns into the
785 * matching call to action. The raw text is preserved in `details` for
786 * debugging — these routes are already admin-gated.
787 *
788 * @since 1.0.0
789 * @param \Exception $e Exception thrown by the Google client.
790 * @param string $code WP_Error code for the failing route.
791 * @return WP_Error Actionable error.
792 */
793 private function google_error_to_wp_error(\Exception $e, string $code): WP_Error {
794 $status = (int) $e->getCode();
795 // The Google client escapes the API's message before wrapping it in the
796 // exception, so decode it back for display — the UI renders `details` as
797 // plain text, where entities would show up literally ("&#039;").
798 $raw = html_entity_decode($e->getMessage(), ENT_QUOTES, 'UTF-8');
799
800 if (stripos($raw, 'valid Search Console site URL') !== false || $status === 404) {
801 $reason = 'no_property';
802 $message = __('The Search Console property for this site is missing or no longer valid. Select your verified property again to restore search data.', 'thinkrank');
803 $http_status = 400;
804 } elseif ($status === 401) {
805 $reason = 'reconnect';
806 $message = __('Your Google connection has expired. Reconnect your Google account to load Search Console data.', 'thinkrank');
807 $http_status = 401;
808 } elseif ($status === 403) {
809 $reason = 'permission';
810 $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');
811 $http_status = 403;
812 } elseif ($status === 429) {
813 $reason = 'quota';
814 $message = __('Google is rate limiting requests right now. Search data will load again shortly.', 'thinkrank');
815 $http_status = 429;
816 } elseif ($status >= 500) {
817 $reason = 'google_down';
818 $message = __('Google Search Console is temporarily unavailable. Please try again in a few minutes.', 'thinkrank');
819 $http_status = 502;
820 } else {
821 $reason = 'unknown';
822 $message = __('Search Console data could not be loaded right now. Please try again.', 'thinkrank');
823 $http_status = 502;
824 }
825
826 return new WP_Error(
827 $code,
828 $message,
829 [
830 'status' => $http_status,
831 'reason' => $reason,
832 'details' => $raw,
833 ]
834 );
835 }
836
837 /**
838 * Check permissions for API access
839 * Following ThinkRank permission patterns
840 *
841 * @return bool Permission status
842 */
843 public function check_permissions(): bool {
844 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_analytics');
845 }
846
847 /**
848 * Check permissions for the Google-backed data routes
849 *
850 * On top of the capability check, requires the SEO Analytics feature
851 * toggle to be enabled. Without this guard a disabled feature would
852 * still hit the Google APIs and surface raw errors (e.g. 401s when no
853 * Google account is connected). Settings read/write routes are not
854 * gated so the feature can always be (re-)enabled.
855 *
856 * @return bool|WP_Error True when allowed, false or WP_Error otherwise
857 */
858 public function check_data_permissions() {
859 if (!$this->check_permissions()) {
860 return false;
861 }
862
863 if (!\ThinkRank\Core\Settings::instance()->get('seo_analytics_enabled', false)) {
864 return new WP_Error(
865 'seo_analytics_disabled',
866 __('SEO Analytics is disabled. Enable it in the SEO Analytics settings to load data.', 'thinkrank'),
867 ['status' => 403]
868 );
869 }
870
871 return true;
872 }
873 }
874