PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.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 All 57 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.14.2, at includes/api/class-seo-analytics-endpoint.php

917 lines 33.6 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' => [$this, 'sanitize_site_url'],
472 'validate_callback' => [$this, 'validate_site_url'],
473 'description' => 'Search Console property: a URL-prefix property (https://example.com/) or a domain property (sc-domain:example.com)'
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 // A domain property (sc-domain:example.com) is not a URL, and
507 // is_valid() refused every one, though the rest of the Search Console
508 // code reads them. Its host must be a real hostname; an IDN is fine.
509 if (0 === stripos($site_url, 'sc-domain:')) {
510 if (null === \ThinkRank\Core\Url_Validator::search_console_domain_property($site_url)) {
511 return new WP_Error(
512 'invalid_site_url',
513 'A domain property must be sc-domain: followed by a domain name, such as sc-domain:example.com',
514 ['status' => 400]
515 );
516 }
517
518 return true;
519 }
520
521 // Url_Validator accepts an internationalised domain, which is a valid
522 // site URL; the raw PHP filter refuses every non-ASCII byte.
523 if (!\ThinkRank\Core\Url_Validator::is_valid($site_url)) {
524 return new WP_Error(
525 'invalid_site_url',
526 'Site URL must be a valid URL',
527 ['status' => 400]
528 );
529 }
530
531 return true;
532 }
533
534 /**
535 * Sanitize the Search Console property sent to setup.
536 *
537 * esc_url_raw() for a URL-prefix property, as before. A domain property
538 * is not a URL: esc_url_raw() drops "sc-domain:" as an unknown protocol
539 * and returns "", so it is normalised to its punycode form instead, which
540 * is what verify_site() matches against the account's property list.
541 *
542 * @since 2.14.2
543 *
544 * @param mixed $site_url Raw parameter, already through validate_site_url().
545 * @return string
546 */
547 public function sanitize_site_url($site_url): string {
548 if (!is_string($site_url)) {
549 return '';
550 }
551
552 $domain = \ThinkRank\Core\Url_Validator::search_console_domain_property($site_url);
553 if (null !== $domain) {
554 return $domain;
555 }
556
557 return esc_url_raw($site_url);
558 }
559
560 /**
561 * Get Search Console totals for a custom date range
562 *
563 * @param WP_REST_Request $request Request object
564 * @return WP_REST_Response|WP_Error Response object
565 */
566 public function get_search_totals(WP_REST_Request $request) {
567 try {
568 $start_date = $request->get_param('start_date');
569 $end_date = $request->get_param('end_date');
570
571 // Validate date format and actual calendar validity
572 $start_dt = \DateTime::createFromFormat('Y-m-d', $start_date);
573 $end_dt = \DateTime::createFromFormat('Y-m-d', $end_date);
574 if (
575 !$start_dt || $start_dt->format('Y-m-d') !== $start_date ||
576 !$end_dt || $end_dt->format('Y-m-d') !== $end_date
577 ) {
578 return new WP_Error('invalid_dates', 'Dates must be valid calendar dates in Y-m-d format', ['status' => 400]);
579 }
580 if ($start_dt > $end_dt) {
581 return new WP_Error('invalid_dates', 'start_date must not be after end_date', ['status' => 400]);
582 }
583
584 // Use Analytics Manager to access the initialized client with decrypted credentials
585 $context = $this->resolve_search_console();
586 if (is_wp_error($context)) {
587 return $context;
588 }
589 [$search_console, $site_url] = $context;
590
591 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
592 $response = $this->cached_response(
593 'search_totals',
594 function () use ($search_console, $site_url, $start_date, $end_date) {
595 return [
596 'success' => true,
597 'data' => $search_console->get_search_totals_by_dates($site_url, $start_date, $end_date),
598 'message' => 'Search totals retrieved',
599 ];
600 },
601 ['start_date' => $start_date, 'end_date' => $end_date]
602 );
603
604 return new WP_REST_Response($response, 200);
605 } catch (\Exception $e) {
606 return $this->google_error_to_wp_error($e, 'search_totals_failed');
607 }
608 }
609
610 /**
611 * Get daily Search Console data grouped by date for chart rendering.
612 *
613 * Returns rows sorted ascending by date, each containing:
614 * clicks, impressions, ctr (as %), position.
615 *
616 * @param WP_REST_Request $request Request object
617 * @return WP_REST_Response|WP_Error Response object
618 */
619 public function get_search_daily(WP_REST_Request $request) {
620 try {
621 $date_range = $request->get_param('date_range') ?: '30d';
622 $days = (int) preg_replace('/[^0-9]/', '', $date_range);
623 if ($days <= 0 || $days > 90) {
624 $days = 30;
625 }
626
627 // Window = exactly $days back from today (inclusive of today).
628 // 7d → today-6 ... today
629 // 30d → today-29 ... today
630 // 90d → today-89 ... today
631 $end_date = gmdate('Y-m-d');
632 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days'));
633
634 $context = $this->resolve_search_console();
635 if (is_wp_error($context)) {
636 return $context;
637 }
638 [$search_console, $site_url] = $context;
639
640 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
641 $response = $this->cached_response(
642 'search_daily',
643 function () use ($search_console, $site_url, $start_date, $end_date, $days) {
644 $raw_rows = $search_console->get_search_performance_by_dates(
645 $site_url,
646 $start_date,
647 $end_date,
648 $days + 5,
649 ['date']
650 );
651
652 // Index GSC rows by date so we can pad missing days (GSC's lag means
653 // the most recent few days often have no data yet).
654 $by_date = [];
655 foreach ($raw_rows as $row) {
656 $date = $row['keys'][0] ?? '';
657 if (!$date) {
658 continue;
659 }
660 $by_date[$date] = [
661 'clicks' => (int) ($row['clicks'] ?? 0),
662 'impressions' => (int) ($row['impressions'] ?? 0),
663 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
664 'position' => round($row['position'] ?? 0, 1),
665 ];
666 }
667
668 // Build a contiguous N-day series from $start_date → $end_date.
669 // Days GSC has no data for (today minus 2-4 days, typically) come
670 // through as zeros so the chart x-axis always spans the full window.
671 $rows = [];
672 $cursor = strtotime($start_date);
673 $end_ts = strtotime($end_date);
674 while ($cursor <= $end_ts) {
675 $date = gmdate('Y-m-d', $cursor);
676 $rows[] = array_merge(
677 ['date' => $date],
678 $by_date[$date] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0]
679 );
680 $cursor = strtotime('+1 day', $cursor);
681 }
682
683 return [
684 'success' => true,
685 'data' => [
686 'rows' => $rows,
687 'start_date' => $start_date,
688 'end_date' => $end_date,
689 ],
690 'message' => 'Daily search data retrieved',
691 ];
692 },
693 ['date_range' => $date_range, 'start_date' => $start_date, 'end_date' => $end_date]
694 );
695
696 return new WP_REST_Response($response, 200);
697 } catch (\Exception $e) {
698 return $this->google_error_to_wp_error($e, 'search_daily_failed');
699 }
700 }
701
702 /**
703 * Get branded vs non-branded query breakdown from Search Console.
704 *
705 * Accepts optional `brand_name` param (comma-separated keywords).
706 * When omitted the brand is auto-derived from the registered domain.
707 * Also returns the equivalent previous-period data so the frontend can
708 * compute trend arrows without a second round-trip.
709 *
710 * @param WP_REST_Request $request Request object
711 * @return WP_REST_Response|WP_Error
712 */
713 public function get_branded(WP_REST_Request $request) {
714 try {
715 $date_range = $request->get_param('date_range') ?: '30d';
716 $brand_name = $request->get_param('brand_name') ?: '';
717
718 $context = $this->resolve_search_console();
719 if (is_wp_error($context)) {
720 return $context;
721 }
722 [$search_console, $site_url] = $context;
723
724 // Cache the (double) live GSC call (3h TTL, site-wide) keyed by
725 // date range + brand terms.
726 $response = $this->cached_response(
727 'branded',
728 function () use ($search_console, $site_url, $date_range, $brand_name) {
729 return [
730 'success' => true,
731 'data' => $search_console->get_branded_performance($site_url, $date_range, $brand_name),
732 'message' => 'Branded data retrieved',
733 ];
734 },
735 ['date_range' => $date_range, 'brand_name' => $brand_name]
736 );
737
738 return new WP_REST_Response($response, 200);
739 } catch (\Exception $e) {
740 return $this->google_error_to_wp_error($e, 'branded_failed');
741 }
742 }
743
744 /**
745 * Get top countries from Search Console (country dimension).
746 *
747 * Returns up to 10 countries sorted by clicks descending, each with
748 * clicks, impressions, ctr, position, and a percentage share of total clicks.
749 *
750 * @param WP_REST_Request $request Request object
751 * @return WP_REST_Response|WP_Error Response object
752 */
753 public function get_countries(WP_REST_Request $request) {
754 try {
755 $date_range = $request->get_param('date_range') ?: '30d';
756
757 $context = $this->resolve_search_console();
758 if (is_wp_error($context)) {
759 return $context;
760 }
761 [$search_console, $site_url] = $context;
762
763 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
764 $response = $this->cached_response(
765 'countries',
766 function () use ($search_console, $site_url, $date_range) {
767 return [
768 'success' => true,
769 'data' => $search_console->get_country_performance($site_url, $date_range),
770 'message' => 'Country data retrieved',
771 ];
772 },
773 ['date_range' => $date_range]
774 );
775
776 return new WP_REST_Response($response, 200);
777 } catch (\Exception $e) {
778 return $this->google_error_to_wp_error($e, 'countries_failed');
779 }
780 }
781
782 /**
783 * Resolve the Search Console client + property URL for the live GSC routes.
784 *
785 * Both failure modes are configuration problems the site owner can fix, so
786 * they return an actionable error instead of letting the request reach
787 * Google and bounce back as raw API text — an unselected property, for
788 * example, otherwise surfaces as
789 * "Google API error (400): 'http://' is not a valid Search Console site URL".
790 *
791 * @since 1.0.0
792 * @return array{0: \ThinkRank\Integrations\Google_Search_Console_Client, 1: string}|WP_Error
793 */
794 private function resolve_search_console() {
795 $client = $this->analytics_manager->get_search_console_client();
796
797 if (!$client) {
798 return new WP_Error(
799 'google_not_connected',
800 __('Google Search Console is not connected yet. Connect your Google account to see search data here.', 'thinkrank'),
801 ['status' => 400, 'reason' => 'not_connected']
802 );
803 }
804
805 $site_url = trim((string) $this->analytics_manager->get_property_url());
806
807 // Accept only the two formats Search Console recognises: a URL-prefix
808 // property (https://example.com/) or a domain property
809 // (sc-domain:example.com). Anything else — most often an empty setting —
810 // means no verified property has been picked yet.
811 if (!preg_match('#^(sc-domain:\S+|https?://\S+)$#i', $site_url)) {
812 return new WP_Error(
813 'no_search_console_property',
814 __('No Search Console property is selected for this site. Choose your verified property to start loading search data.', 'thinkrank'),
815 ['status' => 400, 'reason' => 'no_property']
816 );
817 }
818
819 return [$client, $site_url];
820 }
821
822 /**
823 * Turn a Google API exception into an error a site owner can act on.
824 *
825 * Google's own wording ("'http://' is not a valid Search Console site URL",
826 * bare 401/403s) tells an admin nothing about what to fix, so map the common
827 * statuses to plain-language messages plus a `reason` the UI turns into the
828 * matching call to action. The raw text is preserved in `details` for
829 * debugging — these routes are already admin-gated.
830 *
831 * @since 1.0.0
832 * @param \Exception $e Exception thrown by the Google client.
833 * @param string $code WP_Error code for the failing route.
834 * @return WP_Error Actionable error.
835 */
836 private function google_error_to_wp_error(\Exception $e, string $code): WP_Error {
837 $status = (int) $e->getCode();
838 // The Google client escapes the API's message before wrapping it in the
839 // exception, so decode it back for display — the UI renders `details` as
840 // plain text, where entities would show up literally ("&#039;").
841 $raw = html_entity_decode($e->getMessage(), ENT_QUOTES, 'UTF-8');
842
843 if (stripos($raw, 'valid Search Console site URL') !== false || $status === 404) {
844 $reason = 'no_property';
845 $message = __('The Search Console property for this site is missing or no longer valid. Select your verified property again to restore search data.', 'thinkrank');
846 $http_status = 400;
847 } elseif ($status === 401) {
848 $reason = 'reconnect';
849 $message = __('Your Google connection has expired. Reconnect your Google account to load Search Console data.', 'thinkrank');
850 $http_status = 401;
851 } elseif ($status === 403) {
852 $reason = 'permission';
853 $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');
854 $http_status = 403;
855 } elseif ($status === 429) {
856 $reason = 'quota';
857 $message = __('Google is rate limiting requests right now. Search data will load again shortly.', 'thinkrank');
858 $http_status = 429;
859 } elseif ($status >= 500) {
860 $reason = 'google_down';
861 $message = __('Google Search Console is temporarily unavailable. Please try again in a few minutes.', 'thinkrank');
862 $http_status = 502;
863 } else {
864 $reason = 'unknown';
865 $message = __('Search Console data could not be loaded right now. Please try again.', 'thinkrank');
866 $http_status = 502;
867 }
868
869 return new WP_Error(
870 $code,
871 $message,
872 [
873 'status' => $http_status,
874 'reason' => $reason,
875 'details' => $raw,
876 ]
877 );
878 }
879
880 /**
881 * Check permissions for API access
882 * Following ThinkRank permission patterns
883 *
884 * @return bool Permission status
885 */
886 public function check_permissions(): bool {
887 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_analytics');
888 }
889
890 /**
891 * Check permissions for the Google-backed data routes
892 *
893 * On top of the capability check, requires the SEO Analytics feature
894 * toggle to be enabled. Without this guard a disabled feature would
895 * still hit the Google APIs and surface raw errors (e.g. 401s when no
896 * Google account is connected). Settings read/write routes are not
897 * gated so the feature can always be (re-)enabled.
898 *
899 * @return bool|WP_Error True when allowed, false or WP_Error otherwise
900 */
901 public function check_data_permissions() {
902 if (!$this->check_permissions()) {
903 return false;
904 }
905
906 if (!\ThinkRank\Core\Settings::instance()->get('seo_analytics_enabled', false)) {
907 return new WP_Error(
908 'seo_analytics_disabled',
909 __('SEO Analytics is disabled. Enable it in the SEO Analytics settings to load data.', 'thinkrank'),
910 ['status' => 403]
911 );
912 }
913
914 return true;
915 }
916 }
917