PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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
← All changes | includes/api/class-seo-analytics-endpoint.php +424 -133 1.0.02.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * SEO Analytics API Endpoints Class
4 5 *
5 6 * REST API endpoints for SEO analytics data collection, Google API integration,
@@ -16,8 +17,9 @@
16 17
17 18 namespace ThinkRank\API;
18 19
19 20 use ThinkRank\SEO\Analytics_Manager;
21 +use ThinkRank\API\Traits\API_Cache;
20 22 use WP_REST_Controller;
21 23 use WP_REST_Request;
22 24 use WP_REST_Response;
23 25 use WP_Error;
@@ -26,8 +28,11 @@
26 28 if (!defined('ABSPATH')) {
27 29 exit;
28 30 }
29 31
32 +// Load API Cache trait
33 +require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-api-cache.php';
34 +
30 35 /**
31 36 * SEO Analytics API Endpoints Class
32 37 *
33 38 * Provides REST API endpoints for SEO analytics operations including
@@ -37,8 +42,10 @@
37 42 * @since 1.0.0
38 43 */
39 44 class SEO_Analytics_Endpoint extends WP_REST_Controller {
40 45
46 + use API_Cache;
47 +
41 48 /**
42 49 * Analytics Manager instance
43 50 *
44 51 * @since 1.0.0
@@ -69,8 +76,13 @@
69 76 * @param Analytics_Manager|null $analytics_manager Analytics manager instance
70 77 */
71 78 public function __construct(?Analytics_Manager $analytics_manager = null) {
72 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
73 85 }
74 86
75 87 /**
76 88 * Register API routes
@@ -99,9 +111,9 @@
99 111 [
100 112 [
101 113 'methods' => 'GET',
102 114 'callback' => [$this, 'get_dashboard_data'],
103 - 'permission_callback' => [$this, 'check_permissions'],
115 + 'permission_callback' => [$this, 'check_data_permissions'],
104 116 'args' => $this->get_dashboard_args()
105 117 ]
106 118 ]
107 119 );
@@ -113,9 +125,9 @@
113 125 [
114 126 [
115 127 'methods' => 'GET',
116 128 'callback' => [$this, 'get_seo_opportunities'],
117 - 'permission_callback' => [$this, 'check_permissions'],
129 + 'permission_callback' => [$this, 'check_data_permissions'],
118 130 'args' => $this->get_opportunities_args()
119 131 ]
120 132 ]
121 133 );
@@ -133,21 +145,8 @@
133 145 ]
134 146 ]
135 147 );
136 148
137 - // Get indexing status
138 - register_rest_route(
139 - $this->namespace,
140 - '/' . $this->rest_base . '/indexing-status',
141 - [
142 - [
143 - 'methods' => 'GET',
144 - 'callback' => [$this, 'get_indexing_status'],
145 - 'permission_callback' => [$this, 'check_permissions'],
146 - ]
147 - ]
148 - );
149 -
150 149 // Refresh cached data
151 150 register_rest_route(
152 151 $this->namespace,
153 152 '/' . $this->rest_base . '/refresh',
@@ -172,50 +171,102 @@
172 171 ]
173 172 ]
174 173 );
175 174
176 - // ========================================
177 - // SEO Intelligence Enhancement Endpoints
178 - // ========================================
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 + );
179 201
180 - // Get intelligent dashboard data with trends and insights
202 + // Get daily Search Console data (by date dimension) for chart rendering
181 203 register_rest_route(
182 204 $this->namespace,
183 - '/' . $this->rest_base . '/intelligent-dashboard',
205 + '/' . $this->rest_base . '/search-daily',
184 206 [
185 207 [
186 - 'methods' => 'GET',
187 - 'callback' => [$this, 'get_intelligent_dashboard'],
188 - 'permission_callback' => [$this, 'check_permissions'],
189 - 'args' => $this->get_dashboard_args()
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 + ],
190 220 ]
191 221 ]
192 222 );
193 223
194 - // Get intelligent SEO opportunities with prioritization
224 + // Get branded vs non-branded breakdown from Search Console
195 225 register_rest_route(
196 226 $this->namespace,
197 - '/' . $this->rest_base . '/intelligent-opportunities',
227 + '/' . $this->rest_base . '/branded',
198 228 [
199 229 [
200 - 'methods' => 'GET',
201 - 'callback' => [$this, 'get_intelligent_opportunities'],
202 - 'permission_callback' => [$this, 'check_permissions'],
203 - 'args' => $this->get_opportunities_args()
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 + ],
204 247 ]
205 248 ]
206 249 );
207 250
208 - // Get SEO insights
251 + // Get top countries from Search Console
209 252 register_rest_route(
210 253 $this->namespace,
211 - '/' . $this->rest_base . '/insights',
254 + '/' . $this->rest_base . '/countries',
212 255 [
213 256 [
214 - 'methods' => 'GET',
215 - 'callback' => [$this, 'get_seo_insights'],
216 - 'permission_callback' => [$this, 'check_permissions'],
217 - 'args' => $this->get_dashboard_args()
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 + ],
218 269 ]
219 270 ]
220 271 );
221 272 }
@@ -226,9 +277,9 @@
226 277 *
227 278 * @param WP_REST_Request $request Request object
228 279 * @return WP_REST_Response|WP_Error Response object
229 280 */
230 - public function test_connections(WP_REST_Request $request): WP_REST_Response|WP_Error {
281 + public function test_connections(WP_REST_Request $request) {
231 282 try {
232 283 $connection_results = $this->analytics_manager->test_connections();
233 284
234 285 return new WP_REST_Response([
@@ -235,9 +286,8 @@
235 286 'success' => true,
236 287 'data' => $connection_results,
237 288 'message' => 'Connection tests completed'
238 289 ], 200);
239 -
240 290 } catch (\Exception $e) {
241 291 return new WP_Error(
242 292 'connection_test_failed',
243 293 'Connection test failed: ' . $e->getMessage(),
@@ -251,9 +301,9 @@
251 301 *
252 302 * @param WP_REST_Request $request Request object
253 303 * @return WP_REST_Response|WP_Error Response object
254 304 */
255 - public function get_dashboard_data(WP_REST_Request $request): WP_REST_Response|WP_Error {
305 + public function get_dashboard_data(WP_REST_Request $request) {
256 306 try {
257 307 $date_range = $request->get_param('date_range');
258 308 $dashboard_data = $this->analytics_manager->get_dashboard_data($date_range);
259 309
@@ -261,9 +311,8 @@
261 311 'success' => true,
262 312 'data' => $dashboard_data,
263 313 'message' => 'Dashboard data retrieved successfully'
264 314 ], 200);
265 -
266 315 } catch (\Exception $e) {
267 316 return new WP_Error(
268 317 'dashboard_data_failed',
269 318 'Failed to retrieve dashboard data: ' . $e->getMessage(),
@@ -277,9 +326,9 @@
277 326 *
278 327 * @param WP_REST_Request $request Request object
279 328 * @return WP_REST_Response|WP_Error Response object
280 329 */
281 - public function get_seo_opportunities(WP_REST_Request $request): WP_REST_Response|WP_Error {
330 + public function get_seo_opportunities(WP_REST_Request $request) {
282 331 try {
283 332 $date_range = $request->get_param('date_range');
284 333 $opportunities = $this->analytics_manager->get_seo_opportunities($date_range);
285 334
@@ -287,9 +336,8 @@
287 336 'success' => true,
288 337 'data' => $opportunities,
289 338 'message' => 'SEO opportunities retrieved successfully'
290 339 ], 200);
291 -
292 340 } catch (\Exception $e) {
293 341 return new WP_Error(
294 342 'opportunities_failed',
295 343 'Failed to retrieve SEO opportunities: ' . $e->getMessage(),
@@ -303,9 +351,9 @@
303 351 *
304 352 * @param WP_REST_Request $request Request object
305 353 * @return WP_REST_Response|WP_Error Response object
306 354 */
307 - public function setup_search_console(WP_REST_Request $request): WP_REST_Response|WP_Error {
355 + public function setup_search_console(WP_REST_Request $request) {
308 356 try {
309 357 $site_url = $request->get_param('site_url');
310 358 $setup_result = $this->analytics_manager->setup_search_console_verification($site_url);
311 359
@@ -313,9 +361,8 @@
313 361 'success' => $setup_result['success'],
314 362 'data' => $setup_result,
315 363 'message' => $setup_result['message']
316 364 ], $setup_result['success'] ? 200 : 400);
317 -
318 365 } catch (\Exception $e) {
319 366 return new WP_Error(
320 367 'setup_failed',
321 368 'Search Console setup failed: ' . $e->getMessage(),
@@ -324,48 +371,26 @@
324 371 }
325 372 }
326 373
327 374 /**
328 - * Get indexing status
329 - *
330 - * @param WP_REST_Request $request Request object
331 - * @return WP_REST_Response|WP_Error Response object
332 - */
333 - public function get_indexing_status(WP_REST_Request $request): WP_REST_Response|WP_Error {
334 - try {
335 - $indexing_status = $this->analytics_manager->get_indexing_status();
336 -
337 - return new WP_REST_Response([
338 - 'success' => true,
339 - 'data' => $indexing_status,
340 - 'message' => 'Indexing status retrieved successfully'
341 - ], 200);
342 -
343 - } catch (\Exception $e) {
344 - return new WP_Error(
345 - 'indexing_status_failed',
346 - 'Failed to retrieve indexing status: ' . $e->getMessage(),
347 - ['status' => 500]
348 - );
349 - }
350 - }
351 -
352 - /**
353 375 * Refresh cached analytics data
354 376 *
355 377 * @param WP_REST_Request $request Request object
356 378 * @return WP_REST_Response|WP_Error Response object
357 379 */
358 - public function refresh_data(WP_REST_Request $request): WP_REST_Response|WP_Error {
380 + public function refresh_data(WP_REST_Request $request) {
359 381 try {
360 382 $refresh_result = $this->analytics_manager->refresh_data();
361 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 +
362 388 return new WP_REST_Response([
363 389 'success' => $refresh_result['success'],
364 390 'data' => $refresh_result,
365 391 'message' => $refresh_result['message']
366 392 ], 200);
367 -
368 393 } catch (\Exception $e) {
369 394 return new WP_Error(
370 395 'refresh_failed',
371 396 'Failed to refresh data: ' . $e->getMessage(),
@@ -379,9 +404,9 @@
379 404 *
380 405 * @param WP_REST_Request $request Request object
381 406 * @return WP_REST_Response|WP_Error Response object
382 407 */
383 - public function get_client_status(WP_REST_Request $request): WP_REST_Response|WP_Error {
408 + public function get_client_status(WP_REST_Request $request) {
384 409 try {
385 410 $client_status = $this->analytics_manager->get_client_status();
386 411
387 412 return new WP_REST_Response([
@@ -388,9 +413,8 @@
388 413 'success' => true,
389 414 'data' => $client_status,
390 415 'message' => 'Client status retrieved successfully'
391 416 ], 200);
392 -
393 417 } catch (\Exception $e) {
394 418 return new WP_Error(
395 419 'status_failed',
396 420 'Failed to retrieve client status: ' . $e->getMessage(),
@@ -457,9 +481,21 @@
457 481 *
458 482 * @param string $site_url Site URL to validate
459 483 * @return bool|WP_Error Validation result
460 484 */
461 - public function validate_site_url(string $site_url): bool|WP_Error {
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 +
462 498 if (empty($site_url)) {
463 499 return new WP_Error(
464 500 'invalid_site_url',
465 501 'Site URL is required',
@@ -478,105 +514,360 @@
478 514 return true;
479 515 }
480 516
481 517 /**
482 - * Check permissions for API access
483 - * Following ThinkRank permission patterns
518 + * Get Search Console totals for a custom date range
484 519 *
485 - * @return bool Permission status
520 + * @param WP_REST_Request $request Request object
521 + * @return WP_REST_Response|WP_Error Response object
486 522 */
487 - public function check_permissions(): bool {
488 - return current_user_can('manage_options');
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 + }
489 565 }
490 566
491 - // ========================================
492 - // SEO Intelligence Enhancement Endpoints
493 - // ========================================
494 -
495 567 /**
496 - * Get intelligent dashboard data with trends and insights
568 + * Get daily Search Console data grouped by date for chart rendering.
497 569 *
570 + * Returns rows sorted ascending by date, each containing:
571 + * clicks, impressions, ctr (as %), position.
572 + *
498 573 * @param WP_REST_Request $request Request object
499 574 * @return WP_REST_Response|WP_Error Response object
500 575 */
501 - public function get_intelligent_dashboard(WP_REST_Request $request): WP_REST_Response|WP_Error {
576 + public function get_search_daily(WP_REST_Request $request) {
502 577 try {
503 - $date_range = $request->get_param('date_range');
504 - $intelligent_data = $this->analytics_manager->get_intelligent_dashboard_data($date_range);
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 + }
505 583
506 - $success = isset($intelligent_data['success']) ? $intelligent_data['success'] : false;
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'));
507 590
508 - return new WP_REST_Response([
509 - 'success' => $success,
510 - 'data' => $intelligent_data['data'] ?? null,
511 - 'message' => $intelligent_data['message'] ?? 'Intelligent dashboard data retrieved',
512 - 'timestamp' => current_time('mysql')
513 - ], 200); // Always return 200 for successful API calls, even when no data available
591 + $context = $this->resolve_search_console();
592 + if (is_wp_error($context)) {
593 + return $context;
594 + }
595 + [$search_console, $site_url] = $context;
514 596
515 - } catch (Exception $e) {
516 - return new WP_Error(
517 - 'intelligent_dashboard_error',
518 - 'Failed to retrieve intelligent dashboard data: ' . $e->getMessage(),
519 - ['status' => 500]
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]
520 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');
521 656 }
522 657 }
523 658
524 659 /**
525 - * Get intelligent SEO opportunities with prioritization
660 + * Get branded vs non-branded query breakdown from Search Console.
526 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 + *
527 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
528 708 * @return WP_REST_Response|WP_Error Response object
529 709 */
530 - public function get_intelligent_opportunities(WP_REST_Request $request): WP_REST_Response|WP_Error {
710 + public function get_countries(WP_REST_Request $request) {
531 711 try {
532 - $date_range = $request->get_param('date_range');
533 - $intelligent_opportunities = $this->analytics_manager->get_intelligent_seo_opportunities($date_range);
712 + $date_range = $request->get_param('date_range') ?: '30d';
534 713
535 - $success = isset($intelligent_opportunities['success']) ? $intelligent_opportunities['success'] : false;
714 + $context = $this->resolve_search_console();
715 + if (is_wp_error($context)) {
716 + return $context;
717 + }
718 + [$search_console, $site_url] = $context;
536 719
537 - return new WP_REST_Response([
538 - 'success' => $success,
539 - 'data' => $intelligent_opportunities['data'] ?? null,
540 - 'message' => $intelligent_opportunities['message'] ?? 'Intelligent opportunities retrieved',
541 - 'timestamp' => current_time('mysql')
542 - ], 200); // Always return 200 for successful API calls, even when no data available
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 + );
543 732
544 - } catch (Exception $e) {
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) {
545 755 return new WP_Error(
546 - 'intelligent_opportunities_error',
547 - 'Failed to retrieve intelligent opportunities: ' . $e->getMessage(),
548 - ['status' => 500]
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']
549 759 );
550 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];
551 777 }
552 778
553 779 /**
554 - * Get SEO insights
780 + * Turn a Google API exception into an error a site owner can act on.
555 781 *
556 - * @param WP_REST_Request $request Request object
557 - * @return WP_REST_Response|WP_Error Response object
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.
558 792 */
559 - public function get_seo_insights(WP_REST_Request $request): WP_REST_Response|WP_Error {
560 - try {
561 - $date_range = $request->get_param('date_range');
562 - $insights = $this->analytics_manager->get_seo_insights($date_range);
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');
563 799
564 - $success = isset($insights['success']) ? $insights['success'] : false;
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 + }
565 825
566 - return new WP_REST_Response([
567 - 'success' => $success,
568 - 'data' => $insights['data'] ?? null,
569 - 'cached' => $insights['cached'] ?? false,
570 - 'message' => $insights['message'] ?? 'SEO insights retrieved',
571 - 'timestamp' => current_time('mysql')
572 - ], 200); // Always return 200 for successful API calls, even when no data available
826 + return new WP_Error(
827 + $code,
828 + $message,
829 + [
830 + 'status' => $http_status,
831 + 'reason' => $reason,
832 + 'details' => $raw,
833 + ]
834 + );
835 + }
573 836
574 - } catch (Exception $e) {
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)) {
575 864 return new WP_Error(
576 - 'seo_insights_error',
577 - 'Failed to retrieve SEO insights: ' . $e->getMessage(),
578 - ['status' => 500]
865 + 'seo_analytics_disabled',
866 + __('SEO Analytics is disabled. Enable it in the SEO Analytics settings to load data.', 'thinkrank'),
867 + ['status' => 403]
579 868 );
580 869 }
870 +
871 + return true;
581 872 }
582 873 }