PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.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 / seo / class-analytics-manager.php

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

1,344 lines 50.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Analytics Manager Class
5 *
6 * Coordinates Google API integrations for SEO analytics data collection,
7 * processing, and AI-powered insights generation. Manages Google Analytics,
8 * Search Console, and PageSpeed data with intelligent caching and rate limiting.
9 *
10 * @package ThinkRank
11 * @subpackage SEO
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO;
18
19 use ThinkRank\Core\Settings_Manager;
20 use ThinkRank\Core\Plan_Config;
21 use ThinkRank\Integrations\Google_Analytics_Client;
22 use ThinkRank\Integrations\Google_Search_Console_Client;
23 use ThinkRank\Integrations\Google_PageSpeed_Client;
24 use ThinkRank\Integrations\Google_Search_Analytics_Client;
25 use ThinkRank\Integrations\Google_OAuth_Proxy;
26
27 // Prevent direct access
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 /**
33 * Analytics Manager Class
34 *
35 * Single Responsibility: Coordinate Google API data collection and processing
36 * Following ThinkRank manager patterns from AI_Manager and Performance_Monitoring_Manager
37 *
38 * @since 1.0.0
39 */
40 class Analytics_Manager {
41
42 /**
43 * Settings Manager instance
44 *
45 * @var Settings_Manager
46 */
47 private Settings_Manager $settings_manager;
48
49 /**
50 * Google Analytics client
51 *
52 * @var Google_Analytics_Client|null
53 */
54 private ?Google_Analytics_Client $analytics_client = null;
55
56 /**
57 * Google Search Console client
58 *
59 * @var Google_Search_Console_Client|null
60 */
61 private ?Google_Search_Console_Client $search_console_client = null;
62
63 /**
64 * Google Search Analytics client
65 *
66 * @var Google_Search_Analytics_Client|null
67 */
68 private ?Google_Search_Analytics_Client $search_analytics_client = null;
69
70 /**
71 * Google PageSpeed client
72 *
73 * @var Google_PageSpeed_Client|null
74 */
75 private ?Google_PageSpeed_Client $pagespeed_client = null;
76
77 /**
78 * Cache duration in seconds
79 *
80 * @var int
81 */
82 private int $cache_duration;
83
84 /**
85 * Static flag to prevent multiple token refreshes in the same request
86 *
87 * @var bool
88 */
89 private static bool $token_refreshed_this_request = false;
90
91 /**
92 * Constructor
93 *
94 * @param Settings_Manager|null $settings_manager Settings manager instance
95 */
96 public function __construct(?Settings_Manager $settings_manager = null) {
97 $this->settings_manager = $settings_manager ?? new Settings_Manager();
98 // Pro: daily refresh (86400s), Free: 3-day refresh (259200s)
99 $this->cache_duration = defined('THINKRANK_PRO_VERSION') ? 86400 : 259200;
100 }
101
102 /**
103 * Initialize Analytics Manager
104 * Following ThinkRank init patterns
105 *
106 * @return void
107 */
108 public function init(): void {
109 // Register custom cron interval (45 minutes)
110 add_filter('cron_schedules', [$this, 'add_cron_intervals']);
111
112 // Initialize Google API clients
113 add_action('init', [$this, 'initialize_clients']);
114
115 // Initialize token refresh scheduling
116 add_action('init', [$this, 'init_token_refresh']);
117
118 // Cron hook for token refresh
119 add_action('thinkrank_google_token_refresh', [$this, 'refresh_access_token_cron']);
120
121 // Schedule cache cleanup
122 add_action('thinkrank_daily_cleanup', [$this, 'cleanup_cache']);
123
124 // Cleanup cron on plugin deactivation
125 register_deactivation_hook(THINKRANK_PLUGIN_FILE, [__CLASS__, 'deactivation_cleanup']);
126 }
127
128 /**
129 * Add custom cron intervals
130 *
131 * @param array $schedules Existing cron schedules
132 * @return array Modified cron schedules
133 */
134 public function add_cron_intervals(array $schedules): array {
135 $schedules['thinkrank_45min'] = [
136 'interval' => 2700, // 45 minutes in seconds
137 'display' => __('Every 45 Minutes', 'thinkrank')
138 ];
139 return $schedules;
140 }
141
142 /**
143 * Clean up cron events on plugin deactivation
144 *
145 * @return void
146 */
147 public static function deactivation_cleanup(): void {
148 $timestamp = wp_next_scheduled('thinkrank_google_token_refresh');
149 if ($timestamp) {
150 wp_unschedule_event($timestamp, 'thinkrank_google_token_refresh');
151 }
152 }
153
154 /**
155 * Get the initialized Search Console client
156 *
157 * @return Google_Search_Console_Client|null
158 */
159 public function get_search_console_client(): ?Google_Search_Console_Client {
160 if (!$this->search_console_client) {
161 $this->initialize_clients();
162 }
163 return $this->search_console_client;
164 }
165
166 /**
167 * Get the configured Search Console property URL
168 *
169 * @return string
170 */
171 public function get_property_url(): string {
172 return $this->get_setting('search_console_property', get_site_url());
173 }
174
175 /**
176 * Initialize Google API clients
177 * Following AI_Manager client initialization pattern
178 *
179 * @return void
180 */
181 public function initialize_clients(): void {
182 try {
183 // Refresh token if needed (non-forced, checks expiration)
184 $this->refresh_access_token();
185
186 // Initialize Search Console client
187 $gsc_api_key = $this->get_setting('google_search_console_api_key');
188 $access_token = $this->get_setting('google_access_token');
189
190 $timeout = (int) $this->get_setting('api_timeout', 30);
191 $this->search_console_client = new Google_Search_Console_Client(
192 $gsc_api_key ?: '',
193 $timeout,
194 !empty($access_token) ? $access_token : null
195 );
196
197 // Initialize Search Analytics client
198 $this->search_analytics_client = new Google_Search_Analytics_Client(
199 $gsc_api_key ?: '',
200 $timeout,
201 !empty($access_token) ? $access_token : null
202 );
203
204 // Initialize PageSpeed client. PSI is a public API — it uses the
205 // site's own API key (or keyless per-IP quota), never the shared
206 // OAuth token, which would bill every install's Lighthouse runs
207 // to one exhausted Google Cloud project (429 for everyone).
208 // Shorter timeout here: the dashboard CWV card fetches in-request
209 // on a cold cache and must not stall the whole dashboard payload.
210 $this->pagespeed_client = Google_PageSpeed_Client::for_site(25);
211
212 // Initialize Google Analytics (GA4) client when a property has
213 // been selected. The GA settings UI stores the property in the
214 // Admin API's "properties/XXXXXXXX" form, which is exactly what
215 // the Data API endpoints expect.
216 $ga_property = (string) $this->get_setting('seo_analytics_google_analytics_property_id');
217 if (!empty($access_token) && $ga_property !== '') {
218 if (strpos($ga_property, 'properties/') !== 0) {
219 $ga_property = 'properties/' . $ga_property;
220 }
221 $this->analytics_client = new Google_Analytics_Client('', $ga_property, $timeout, $access_token);
222 }
223 } catch (\Exception $e) {
224 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
225 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
226 error_log('ThinkRank Analytics Init Error: ' . $e->getMessage());
227 }
228 }
229 }
230
231 /**
232 * Initialize token refresh scheduling
233 * Also migrates old absolute-timestamp expires_in values to relative seconds
234 *
235 * @return void
236 */
237 public function init_token_refresh(): void {
238 $access_token = $this->get_setting('google_access_token');
239 $refresh_token = $this->get_setting('google_refresh_token');
240
241 if (empty($access_token) || empty($refresh_token)) {
242 return;
243 }
244
245 // Migrate old expires_in values stored as absolute timestamps
246 $this->maybe_migrate_expires_in();
247
248 // Schedule recurring hourly cron for token refresh
249 $this->schedule_token_refresh();
250 }
251
252 /**
253 * Migrate old expires_in values from absolute timestamps to relative seconds
254 *
255 * Old callback.php stored expires_in as time() + token->expires_in (e.g., 1771330205).
256 * New behavior stores raw seconds from Google (e.g., 3599).
257 *
258 * @return void
259 */
260 private function maybe_migrate_expires_in(): void {
261 $expires_in = (int) $this->get_setting('google_token_expires_in');
262 $created = (int) $this->get_setting('google_token_created');
263
264 // Google tokens expire in 3600 seconds max. If stored value is > 86400,
265 // it's almost certainly the old absolute timestamp format.
266 if ($expires_in > 86400 && $created > 0) {
267 $relative = $expires_in - $created;
268 if ($relative > 0 && $relative <= 7200) {
269 // Valid relative value, save the corrected value
270 $this->settings_manager->update_settings([
271 'google_token_expires_in' => $relative
272 ], 'integrations');
273 } else {
274 // Can't reliably compute, default to standard 3600
275 $this->settings_manager->update_settings([
276 'google_token_expires_in' => 3600
277 ], 'integrations');
278 }
279 $this->merged_settings = null;
280 }
281 }
282
283 /**
284 * Schedule recurring cron for token refresh (every 45 minutes)
285 *
286 * Uses WP recurring cron instead of single events for reliability.
287 * The cron callback checks expiration and only refreshes when needed.
288 * Using 45-minute interval ensures the cron always fires before
289 * Google's ~60-minute token expiry window.
290 *
291 * @return void
292 */
293 public function schedule_token_refresh(): void {
294 $next = wp_next_scheduled('thinkrank_google_token_refresh');
295
296 // If already scheduled with the old 'hourly' interval, reschedule with 45min
297 if ($next) {
298 // Check if it's using the old interval by looking at the schedule
299 $crons = _get_cron_array();
300 foreach ($crons as $timestamp => $cron_hooks) {
301 if (isset($cron_hooks['thinkrank_google_token_refresh'])) {
302 foreach ($cron_hooks['thinkrank_google_token_refresh'] as $hash => $args) {
303 if (($args['schedule'] ?? '') === 'hourly') {
304 // Remove old hourly schedule and re-add with 45min
305 wp_unschedule_event($timestamp, 'thinkrank_google_token_refresh');
306 $next = false; // Will be rescheduled below
307 }
308 }
309 break;
310 }
311 }
312 }
313
314 if (!$next) {
315 wp_schedule_event(time(), 'thinkrank_45min', 'thinkrank_google_token_refresh');
316 }
317 }
318
319 /**
320 * Cron callback for token refresh
321 * Called every 45 minutes; only refreshes if token is expired or expiring soon.
322 *
323 * @return void
324 */
325 public function refresh_access_token_cron(): void {
326 $this->refresh_access_token();
327 }
328
329 /**
330 * Ensure the Google access token is fresh before making API calls.
331 *
332 * This is a static convenience method that can be called from any endpoint
333 * (including the Pro plugin) before making Google API requests.
334 * Uses a per-request flag to avoid redundant refreshes when multiple
335 * endpoints are called in the same HTTP request.
336 *
337 * @since 1.6.0
338 * @return void
339 */
340 public static function ensure_fresh_token(): void {
341 // Only refresh once per HTTP request to avoid parallel race conditions
342 if (self::$token_refreshed_this_request) {
343 return;
344 }
345
346 $manager = new self();
347 $manager->refresh_access_token();
348 self::$token_refreshed_this_request = true;
349 }
350
351 /**
352 * Refresh OAuth access token if expired or expiring soon
353 *
354 * @since 1.5.0
355 * @param bool $force Force refresh even if not expired
356 * @return void
357 */
358 public function refresh_access_token(bool $force = false): void {
359 $refresh_token = $this->get_setting('google_refresh_token');
360
361 // If no refresh token, we can't refresh
362 if (empty($refresh_token)) {
363 return;
364 }
365
366 $expires_in = (int) $this->get_setting('google_token_expires_in');
367 $created = (int) $this->get_setting('google_token_created');
368 $current_time = time();
369
370 // Calculate absolute expiration time (created + relative seconds)
371 $expiration_time = $created + $expires_in;
372
373 // Refresh if forced, expired, or expiring within 5 minutes (300 seconds)
374 if ($force || $current_time >= ($expiration_time - 300)) {
375
376 // The proxy owns the Google app credentials; we only ever hand it
377 // the refresh token and let it perform the exchange.
378 $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [
379 'headers' => [
380 'Content-Type' => 'application/json',
381 'Accept' => 'application/json',
382 ],
383 'body' => wp_json_encode([
384 'action' => 'refresh',
385 'refresh_token' => $refresh_token,
386 'site' => home_url(),
387 ]),
388 'timeout' => 30
389 ]);
390
391 if (is_wp_error($response)) {
392 return;
393 }
394
395 $body = wp_remote_retrieve_body($response);
396 $data = json_decode($body, true);
397
398 if (empty($data['access_token'])) {
399 // invalid_grant is terminal: the user revoked access in their
400 // Google account, or the refresh token was superseded by a
401 // newer grant. Retrying can never succeed, so stop pretending
402 // the site is connected — otherwise the UI shows "Connected"
403 // while every API call 401s.
404 if (($data['error'] ?? '') === 'invalid_grant') {
405 Google_OAuth_Proxy::mark_revoked();
406 }
407
408 // Any other failure (network blip, proxy 502) is transient;
409 // leave the credentials alone and let the next run retry.
410 return;
411 }
412
413 // Update settings with new token data
414 $this->settings_manager->update_settings([
415 'google_access_token' => $data['access_token'],
416 'google_token_created' => $current_time,
417 'google_token_expires_in' => (int) ($data['expires_in'] ?? 3600)
418 ], 'integrations');
419
420 // Also update refresh token if a new one was returned
421 if (!empty($data['refresh_token'])) {
422 $this->settings_manager->update_settings([
423 'google_refresh_token' => $data['refresh_token']
424 ], 'integrations');
425 }
426
427 // Drop the memoized settings merge so subsequent reads (e.g.
428 // re-initializing clients) see the fresh token.
429 $this->merged_settings = null;
430 }
431 }
432
433 /**
434 * Test all Google API connections
435 * Following ThinkRank test_connection patterns
436 *
437 * @return array Connection test results
438 */
439 public function test_connections(): array {
440 $results = [
441 'google_analytics' => ['status' => 'not_configured'],
442 'search_console' => ['status' => 'not_configured'],
443 'pagespeed' => ['status' => 'not_configured']
444 ];
445
446 // Test Google Analytics connection
447 if ($this->analytics_client) {
448 try {
449 $test_result = $this->analytics_client->test_connection();
450 $results['google_analytics'] = [
451 'status' => $test_result['success'] ? 'connected' : 'error',
452 'message' => $test_result['message'],
453 'details' => $test_result
454 ];
455 } catch (\Exception $e) {
456 $results['google_analytics'] = [
457 'status' => 'error',
458 'message' => $e->getMessage()
459 ];
460 }
461 }
462
463 // Test Search Console connection
464 if ($this->search_console_client) {
465 try {
466 $test_result = $this->search_console_client->test_connection();
467 $results['search_console'] = [
468 'status' => $test_result['success'] ? 'connected' : 'error',
469 'message' => $test_result['message'],
470 'details' => $test_result
471 ];
472 } catch (\Exception $e) {
473 $results['search_console'] = [
474 'status' => 'error',
475 'message' => $e->getMessage()
476 ];
477 }
478 }
479
480 // Test PageSpeed connection
481 if ($this->pagespeed_client) {
482 try {
483 $test_result = $this->pagespeed_client->test_connection();
484 $results['pagespeed'] = [
485 'status' => $test_result['success'] ? 'connected' : 'error',
486 'message' => $test_result['message'],
487 'details' => $test_result
488 ];
489 } catch (\Exception $e) {
490 $results['pagespeed'] = [
491 'status' => 'error',
492 'message' => $e->getMessage()
493 ];
494 }
495 }
496
497 return $results;
498 }
499
500 /**
501 * Get analytics dashboard data
502 * Combines data from all Google APIs with caching
503 *
504 * @param string $date_range Date range for data
505 * @return array Dashboard data
506 *
507 * @throws \Exception On failure.
508 */
509 public function get_dashboard_data(string $date_range = '30d'): array {
510 $cache_key = "analytics_dashboard_v5_{$date_range}";
511 $cached_data = get_transient($cache_key);
512
513 if ($cached_data !== false) {
514 // Core Web Vitals are cached separately with a much shorter
515 // lifetime than the GSC data (and failures are never cached), so
516 // a transient PageSpeed failure can't blank the CWV card for the
517 // dashboard cache's full 1-3 day TTL.
518 $cached_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
519 return $cached_data;
520 }
521
522 $dashboard_data = [
523 'traffic' => [],
524 'search_performance' => [],
525 'core_web_vitals' => [],
526 'last_updated' => current_time('mysql'),
527 'date_range' => $date_range
528 ];
529
530 $retry_count = 0;
531 $max_retries = 1;
532
533 while ($retry_count <= $max_retries) {
534 try {
535 // Ensure clients are initialized (lazy load) before any of
536 // them are used — this also builds the GA4 client when a
537 // property is configured.
538 if (!$this->search_console_client || !$this->search_analytics_client) {
539 $this->initialize_clients();
540 }
541
542 // Get Google Analytics traffic data. GA is optional — an
543 // isolated failure (misconfigured property, missing scope)
544 // must not abort the Search Console portion of the dashboard.
545 // 401s are re-thrown so the token-refresh retry below runs.
546 if ($this->analytics_client) {
547 try {
548 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
549 $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
550 $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
551 } catch (\Exception $ga_error) {
552 if ($ga_error->getCode() === 401) {
553 throw $ga_error;
554 }
555 $dashboard_data['traffic'] = [];
556 $dashboard_data['traffic_error'] = $ga_error->getMessage();
557 }
558 }
559
560 // Get Search Console data
561 if ($this->search_console_client) {
562 $site_url = $this->get_setting('search_console_property', get_site_url());
563 // Get totals
564 $totals = $this->search_console_client->get_search_totals($site_url, $date_range);
565
566 // Get performance data (keywords) using new client
567 if ($this->search_analytics_client) {
568 // GSC data has a 2-day delay; use D-2 as end_date to match the GSC dashboard.
569 $days = (int) str_replace('d', '', $date_range);
570 $end_date = gmdate('Y-m-d', strtotime('-2 days'));
571 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date)));
572
573 $search_performance = $this->search_analytics_client->get_search_analytics_data(
574 $site_url,
575 $start_date,
576 $end_date,
577 ['query'],
578 1000
579 );
580 } else {
581 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
582 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
583 }
584
585 // Calculate position distribution
586 $position_distribution = [
587 'top_3' => 0,
588 '4_10' => 0,
589 '10_50' => 0,
590 '51_100' => 0
591 ];
592
593 foreach ($search_performance['rows'] ?? [] as $row) {
594 $position = $row['position'] ?? 0;
595 if ($position <= 3) {
596 $position_distribution['top_3']++;
597 } elseif ($position <= 10) {
598 $position_distribution['4_10']++;
599 } elseif ($position <= 50) {
600 $position_distribution['10_50']++;
601 } elseif ($position <= 100) {
602 $position_distribution['51_100']++;
603 }
604 }
605
606 $dashboard_data['search_performance'] = array_merge($search_performance, [
607 'totals' => $totals,
608 'position_distribution' => $position_distribution
609 ]);
610
611 $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
612 } // Closing Search Console block
613
614 // If successful, break loop
615 break;
616 } catch (\Exception $e) {
617 // Check for 401 error
618 if ($e->getCode() === 401 && $retry_count < $max_retries) {
619 $this->refresh_access_token(true); // Force refresh
620
621 // Re-initialize clients with new token
622 $this->initialize_clients();
623
624 $retry_count++;
625 continue;
626 }
627 $dashboard_data['error'] = $e->getMessage();
628 break;
629 }
630 }
631
632 // Add last updated timestamp
633 $dashboard_data['last_updated'] = current_time('mysql');
634
635 // Cache the results — but never cache an error payload, otherwise a
636 // transient failure (e.g. a Google API 401) would be served from the
637 // cache for the full TTL even after the underlying issue is fixed.
638 // Core Web Vitals are deliberately NOT part of this cache (see below).
639 if (empty($dashboard_data['error'])) {
640 set_transient($cache_key, $dashboard_data, $this->cache_duration);
641 }
642
643 // Merge Core Web Vitals from their own short-lived cache after the
644 // long-lived GSC payload has been stored.
645 $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
646
647 return $dashboard_data;
648 }
649
650 /**
651 * Get Core Web Vitals for the analytics dashboard, cached independently
652 * of the dashboard payload.
653 *
654 * Successful results are cached for 1 hour; failures are never cached
655 * here (the PageSpeed client itself remembers failures for a few minutes
656 * to avoid re-blocking requests on a broken URL), so CWV recovers as soon
657 * as PageSpeed does instead of staying empty for the dashboard cache's
658 * 1-3 day TTL.
659 *
660 * @return array Core Web Vitals data, or an error payload
661 */
662 private function get_dashboard_core_web_vitals(): array {
663 $cached = get_transient('thinkrank_dashboard_cwv');
664 if (is_array($cached)) {
665 return $cached;
666 }
667
668 if (!$this->pagespeed_client) {
669 $this->initialize_clients();
670 }
671
672 if (!$this->pagespeed_client) {
673 return [];
674 }
675
676 try {
677 $core_web_vitals = $this->pagespeed_client->get_core_web_vitals(get_site_url());
678 set_transient('thinkrank_dashboard_cwv', $core_web_vitals, HOUR_IN_SECONDS);
679 return $core_web_vitals;
680 } catch (\Exception $psi_error) {
681 return [
682 'error' => $psi_error->getMessage(),
683 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
684 ];
685 }
686 }
687
688 /**
689 * Get SEO opportunities using Search Console data
690 *
691 * @param string $date_range Date range for analysis
692 * @return array SEO opportunities
693 */
694 public function get_seo_opportunities(string $date_range = '30d'): array {
695 $cache_key = "seo_opportunities_{$date_range}";
696 $cached_data = get_transient($cache_key);
697
698 if ($cached_data !== false) {
699 return $cached_data;
700 }
701
702 $opportunities = [
703 'keyword_opportunities' => [],
704 'page_opportunities' => [],
705 'device_insights' => [],
706 'last_updated' => current_time('mysql')
707 ];
708
709 $retry_count = 0;
710 $max_retries = 1;
711
712 while ($retry_count <= $max_retries) {
713 try {
714 if ($this->search_console_client) {
715 $site_url = $this->get_setting('search_console_property', get_site_url());
716
717 // Get keyword opportunities
718 $opportunities['keyword_opportunities'] = $this->search_console_client->get_keyword_opportunities($site_url, $date_range);
719
720 // Get device performance insights
721 $opportunities['device_insights'] = $this->search_console_client->get_device_performance($site_url, $date_range);
722
723 // Get search appearance data
724 $opportunities['search_appearance'] = $this->search_console_client->get_search_appearance($site_url, $date_range);
725 }
726
727 // If successful, break loop
728 break;
729 } catch (\Exception $e) {
730 // Check for 401 error
731 if ($e->getCode() === 401 && $retry_count < $max_retries) {
732 $this->refresh_access_token(true); // Force refresh
733
734 // Re-initialize clients with new token
735 $this->initialize_clients();
736
737 $retry_count++;
738 continue;
739 }
740
741 $opportunities['error'] = $e->getMessage();
742 break;
743 }
744 }
745
746 // Cache the results — but never cache an error payload (see
747 // get_dashboard_data() for rationale).
748 if (empty($opportunities['error'])) {
749 set_transient($cache_key, $opportunities, $this->cache_duration);
750 }
751
752 return $opportunities;
753 }
754
755 /**
756 * Memoized merge of the two settings categories this manager reads from.
757 * Rebuilt when settings are updated through update_settings() below.
758 *
759 * @var array|null
760 */
761 private ?array $merged_settings = null;
762
763 private function get_setting(string $key, $fallback = '') {
764 if ($this->merged_settings === null) {
765 // Merge settings to allow access to both categories. Memoized:
766 // this getter is called many times per request and each category
767 // read decrypts every sensitive option again.
768 $this->merged_settings = array_merge(
769 $this->settings_manager->get_settings('integrations'),
770 $this->settings_manager->get_settings('seo_analytics')
771 );
772 }
773
774 return $this->merged_settings[$key] ?? $fallback;
775 }
776
777 /**
778 * One-click setup for Google Search Console verification
779 * Following ThinkRank setup patterns
780 *
781 * @param string $site_url Site URL to verify
782 * @return array Setup results
783 */
784 public function setup_search_console_verification(string $site_url): array {
785 try {
786 if (!$this->search_console_client) {
787 return [
788 'success' => false,
789 'message' => 'Search Console API key not configured'
790 ];
791 }
792
793 $verification_result = $this->search_console_client->verify_site($site_url);
794
795 if ($verification_result['success']) {
796 // Update settings with verified site URL
797 $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
798 $this->merged_settings = null;
799 }
800
801 return $verification_result;
802 } catch (\Exception $e) {
803 return [
804 'success' => false,
805 'message' => $e->getMessage()
806 ];
807 }
808 }
809
810 /**
811 * Get site indexing status
812 *
813 * @return array Indexing status data
814 */
815 public function get_indexing_status(): array {
816 $cache_key = 'indexing_status';
817 $cached_data = get_transient($cache_key);
818
819 if ($cached_data !== false) {
820 return $cached_data;
821 }
822
823 $indexing_data = [
824 'status' => 'unknown',
825 'last_updated' => current_time('mysql')
826 ];
827
828 try {
829 if ($this->search_console_client) {
830 $site_url = $this->get_setting('search_console_property', get_site_url());
831 $indexing_data = $this->search_console_client->get_indexing_status($site_url);
832 }
833 } catch (\Exception $e) {
834 $indexing_data['error'] = $e->getMessage();
835 }
836
837 // Cache successful results for 1 hour. Errors are never cached, so a
838 // transient Google failure isn't served as "no data" for a full hour.
839 if (empty($indexing_data['error'])) {
840 set_transient($cache_key, $indexing_data, 3600);
841 }
842
843 return $indexing_data;
844 }
845
846 /**
847 * Force refresh of all cached data
848 *
849 * @return array Refresh results
850 */
851 public function refresh_data(): array {
852 // Clear all analytics-related transients, including the previous-period
853 // ranges used for trend comparison (14d/60d/180d) and the separately
854 // cached Core Web Vitals payload.
855 $cache_keys = [
856 'analytics_dashboard_v5_7d',
857 'analytics_dashboard_v5_30d',
858 'analytics_dashboard_v5_90d',
859 'analytics_dashboard_v5_14d',
860 'analytics_dashboard_v5_60d',
861 'analytics_dashboard_v5_180d',
862 'seo_opportunities_7d',
863 'seo_opportunities_30d',
864 'seo_opportunities_90d',
865 'seo_insights_7d',
866 'seo_insights_30d',
867 'seo_insights_90d',
868 'indexing_status',
869 'thinkrank_dashboard_cwv'
870 ];
871
872 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
873 // URL + device, so compute them for the URL/device combinations the
874 // plugin actually tests.
875 foreach (array_unique([home_url(), get_site_url()]) as $url) {
876 foreach (['mobile', 'desktop'] as $device) {
877 $psi_hash = md5($url . '|' . $device);
878 $legacy_hash = md5($url . '_' . $device);
879 $cache_keys[] = 'thinkrank_psi_snapshot_' . $psi_hash;
880 $cache_keys[] = 'thinkrank_psi_failure_' . $psi_hash;
881 $cache_keys[] = 'thinkrank_core_web_vitals_' . $legacy_hash;
882 $cache_keys[] = 'thinkrank_opportunities_' . $legacy_hash;
883 $cache_keys[] = 'thinkrank_diagnostics_' . $legacy_hash;
884 }
885 }
886
887 $cleared = 0;
888 foreach ($cache_keys as $key) {
889 if (delete_transient($key)) {
890 $cleared++;
891 }
892 }
893
894 return [
895 'success' => true,
896 'message' => "Cleared {$cleared} cached data entries",
897 'cleared_count' => $cleared,
898 'timestamp' => current_time('mysql')
899 ];
900 }
901
902 /**
903 * Get client status for debugging
904 *
905 * @return array Client status information
906 */
907 public function get_client_status(): array {
908 return [
909 'google_analytics' => [
910 'initialized' => !is_null($this->analytics_client),
911 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
912 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
913 ],
914 'search_console' => [
915 'initialized' => !is_null($this->search_console_client),
916 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
917 'site_url_configured' => !empty($this->get_setting('search_console_property'))
918 ],
919 'pagespeed' => [
920 'initialized' => !is_null($this->pagespeed_client),
921 'api_key_configured' => !empty($this->get_setting('google_pagespeed_api_key'))
922 ],
923 'cache_duration' => $this->cache_duration,
924 'last_checked' => current_time('mysql')
925 ];
926 }
927
928 /**
929 * Cleanup expired cache data
930 * Following ThinkRank cache cleanup patterns
931 *
932 * @return void
933 */
934 public function cleanup_cache(): void {
935 // WordPress handles transient cleanup automatically
936 // This method is for future custom cache cleanup if needed
937 }
938
939 // ========================================
940 // SEO Intelligence Enhancement Methods
941 // ========================================
942
943 /**
944 * Get intelligent dashboard data with trends and insights
945 *
946 * @param string $date_range Date range for analysis
947 * @return array Enhanced dashboard data with intelligence
948 */
949 public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
950 // Get base dashboard data
951 $dashboard_data = $this->get_dashboard_data($date_range);
952
953 // Check if there's an error in the data
954 if (isset($dashboard_data['error'])) {
955 return [
956 'success' => false,
957 'data' => null,
958 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
959 'timestamp' => current_time('mysql')
960 ];
961 }
962
963 // Check if we have real data available
964 if (!$this->has_real_data($dashboard_data)) {
965 return [
966 'success' => false,
967 'data' => null,
968 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
969 'timestamp' => current_time('mysql')
970 ];
971 }
972
973 // Intelligence engine is a Pro-only feature. The four SEO_* classes ship
974 // in Free too (the PSR-4 autoloader would resolve them), so class_exists()
975 // can't gate this — check the real Pro signal instead.
976 if (!Plan_Config::is_pro()) {
977 return [
978 'success' => false,
979 'data' => null,
980 'message' => 'Intelligent dashboard requires ThinkRank Pro.',
981 'timestamp' => current_time('mysql')
982 ];
983 }
984
985 $trend_analyzer = new SEO_Trend_Analyzer();
986 $scoring_engine = new SEO_Scoring_Engine();
987 $insight_generator = new SEO_Insight_Generator();
988
989 $data = $dashboard_data;
990
991 // Generate trend analysis
992 $current_data = $data;
993 $historical_data = $this->get_historical_data($date_range);
994
995 $trends = [
996 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
997 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
998 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
999 ];
1000
1001 // Calculate SEO health score
1002 $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
1003
1004 // Generate insights
1005 $insights = [
1006 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
1007 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
1008 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
1009 ];
1010
1011 // Combine all intelligence data
1012 $enhanced_data = array_merge($data, [
1013 'intelligence' => [
1014 'trends' => $trends,
1015 'seo_health_score' => $seo_health,
1016 'insights' => $insights,
1017 'last_analyzed' => current_time('mysql')
1018 ]
1019 ]);
1020
1021 return [
1022 'success' => true,
1023 'data' => $enhanced_data,
1024 'message' => 'Intelligent dashboard data retrieved successfully'
1025 ];
1026 }
1027
1028 /**
1029 * Get intelligent SEO opportunities with prioritization
1030 *
1031 * @param string $date_range Date range for analysis
1032 * @return array Enhanced opportunities with intelligence
1033 */
1034 public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
1035 // Get base opportunities data
1036 $opportunities_data = $this->get_seo_opportunities($date_range);
1037
1038 // Check if there's an error in the data
1039 if (isset($opportunities_data['error'])) {
1040 return [
1041 'success' => false,
1042 'data' => null,
1043 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
1044 'timestamp' => current_time('mysql')
1045 ];
1046 }
1047
1048 // The opportunities payload itself has no search_performance key — that
1049 // data lives in the (cached) dashboard payload. Pull it from there both
1050 // for the availability check and as input for the opportunity detectors;
1051 // checking $opportunities_data['search_performance'] here used to make
1052 // this method always bail with "No Search Console data available".
1053 $dashboard_data = $this->get_dashboard_data($date_range);
1054 $search_performance = $dashboard_data['search_performance'] ?? [];
1055 $opportunities_data['search_performance'] = $search_performance;
1056
1057 $has_search_data = !empty($search_performance['rows']) ||
1058 ($search_performance['total_clicks'] ?? 0) > 0 ||
1059 ($search_performance['total_impressions'] ?? 0) > 0;
1060
1061 if (!$has_search_data) {
1062 return [
1063 'success' => false,
1064 'data' => null,
1065 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
1066 'timestamp' => current_time('mysql')
1067 ];
1068 }
1069
1070 // Intelligence engine is a Pro-only feature — gate on the real Pro signal,
1071 // not class_exists() (the classes ship in Free and would autoload).
1072 if (!Plan_Config::is_pro()) {
1073 return [
1074 'success' => false,
1075 'data' => null,
1076 'message' => 'Intelligent opportunities require ThinkRank Pro.',
1077 'timestamp' => current_time('mysql')
1078 ];
1079 }
1080
1081 $opportunity_detector = new SEO_Opportunity_Detector();
1082 $scoring_engine = new SEO_Scoring_Engine();
1083
1084 $data = $opportunities_data;
1085
1086 // Detect intelligent opportunities
1087 $search_console_data = $data['search_performance'] ?? [];
1088 $analytics_data = $data;
1089
1090 $intelligent_opportunities = [
1091 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
1092 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
1093 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
1094 ];
1095
1096 // Prioritize all opportunities. prioritize_opportunities() expects
1097 // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list.
1098 $opportunities_by_category = [
1099 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
1100 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
1101 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [],
1102 ];
1103 $all_opportunities = array_merge(...array_values($opportunities_by_category));
1104
1105 $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category);
1106 $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
1107
1108 // Enhance original data with intelligence
1109 $enhanced_data = array_merge($data, [
1110 'intelligent_opportunities' => $intelligent_opportunities,
1111 'prioritized_opportunities' => $prioritized,
1112 'impact_effort_matrix' => $impact_matrix,
1113 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
1114 'last_analyzed' => current_time('mysql')
1115 ]);
1116
1117 return [
1118 'success' => true,
1119 'data' => $enhanced_data,
1120 'message' => 'Intelligent SEO opportunities retrieved successfully'
1121 ];
1122 }
1123
1124 /**
1125 * Get SEO performance insights
1126 *
1127 * @param string $date_range Date range for analysis
1128 * @return array SEO insights data
1129 */
1130 public function get_seo_insights(string $date_range = '30d'): array {
1131 $cache_key = "seo_insights_{$date_range}";
1132 $cached_data = get_transient($cache_key);
1133
1134 if ($cached_data !== false) {
1135 return [
1136 'success' => true,
1137 'data' => $cached_data,
1138 'cached' => true,
1139 'message' => 'SEO insights retrieved from cache'
1140 ];
1141 }
1142
1143 try {
1144 // Get dashboard data for analysis
1145 $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
1146
1147 if (!$dashboard_result['success']) {
1148 return $dashboard_result;
1149 }
1150
1151 $dashboard_data = $dashboard_result['data'];
1152 $intelligence = $dashboard_data['intelligence'] ?? [];
1153
1154 // Insights are a Pro-only feature — gate on the real Pro signal,
1155 // not class_exists() (SEO_Insight_Generator ships in Free too).
1156 if (!Plan_Config::is_pro()) {
1157 return [
1158 'success' => false,
1159 'data' => null,
1160 'message' => 'SEO insights require ThinkRank Pro.',
1161 'timestamp' => current_time('mysql')
1162 ];
1163 }
1164
1165 $insight_generator = new SEO_Insight_Generator();
1166
1167 // Collect all insights
1168 $all_insights = [];
1169
1170 if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
1171 $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
1172 }
1173
1174 if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
1175 $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
1176 }
1177
1178 if (!empty($intelligence['insights']['content_insights']['insights'])) {
1179 $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
1180 }
1181
1182 // Format and prioritize insights
1183 $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
1184 $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
1185
1186 $insights_data = [
1187 'insights' => $prioritized_insights['prioritized_insights'],
1188 'summary' => [
1189 'total_insights' => count($formatted_insights),
1190 'high_impact_count' => $prioritized_insights['high_impact_count'],
1191 'action_required_count' => $prioritized_insights['action_required_count']
1192 ],
1193 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
1194 'generated_at' => current_time('mysql')
1195 ];
1196
1197 // Cache the results
1198 set_transient($cache_key, $insights_data, $this->cache_duration);
1199
1200 return [
1201 'success' => true,
1202 'data' => $insights_data,
1203 'cached' => false,
1204 'message' => 'SEO insights generated successfully'
1205 ];
1206 } catch (\Exception $e) {
1207 return [
1208 'success' => false,
1209 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
1210 'data' => null
1211 ];
1212 }
1213 }
1214
1215 /**
1216 * Check if real analytics data is available
1217 *
1218 * @param array $dashboard_data Dashboard data to check
1219 * @return bool True if real data is available
1220 */
1221 private function has_real_data(array $dashboard_data): bool {
1222 // Check if we have meaningful traffic data
1223 $traffic = $dashboard_data['traffic'] ?? [];
1224 $search_performance = $dashboard_data['search_performance'] ?? [];
1225
1226 $has_traffic = !empty($traffic) && (
1227 ($traffic['sessions'] ?? 0) > 0 ||
1228 ($traffic['pageviews'] ?? 0) > 0 ||
1229 ($traffic['active_users'] ?? 0) > 0
1230 );
1231
1232 $has_search_data = !empty($search_performance) && (
1233 !empty($search_performance['rows']) ||
1234 ($search_performance['total_clicks'] ?? 0) > 0 ||
1235 ($search_performance['total_impressions'] ?? 0) > 0
1236 );
1237
1238 return $has_traffic || $has_search_data;
1239 }
1240
1241 /**
1242 * Get historical data for trend comparison
1243 *
1244 * @param string $current_range Current date range
1245 * @return array Historical data
1246 */
1247 private function get_historical_data(string $current_range): array {
1248 // Calculate previous period based on current range
1249 $previous_range = $this->calculate_previous_period($current_range);
1250
1251 // Try to get actual historical data from previous period
1252 $historical_data = $this->get_dashboard_data($previous_range);
1253
1254 // Return the actual historical data (may be empty if no real data available)
1255 return [
1256 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
1257 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
1258 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
1259 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
1260 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
1261 ];
1262 }
1263
1264 /**
1265 * Calculate previous period for comparison
1266 *
1267 * @param string $current_range Current range
1268 * @return string Previous period range
1269 */
1270 private function calculate_previous_period(string $current_range): string {
1271 // Simple mapping for now - could be enhanced with actual date calculations
1272 $period_mapping = [
1273 '7d' => '14d',
1274 '30d' => '60d',
1275 '90d' => '180d'
1276 ];
1277
1278 return $period_mapping[$current_range] ?? '60d';
1279 }
1280
1281 /**
1282 * Generate opportunity summary
1283 *
1284 * @param array $opportunities All opportunities
1285 * @return array Opportunity summary
1286 */
1287 private function generate_opportunity_summary(array $opportunities): array {
1288 $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
1289 $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
1290 $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
1291
1292 $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
1293
1294 $potential_clicks = 0;
1295 if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
1296 $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
1297 }
1298
1299 return [
1300 'total_opportunities' => $total_opportunities,
1301 'quick_wins_count' => $quick_wins_count,
1302 'content_opportunities_count' => $content_opportunities_count,
1303 'keyword_opportunities_count' => $keyword_opportunities_count,
1304 'potential_additional_clicks' => $potential_clicks,
1305 'priority_recommendation' => $quick_wins_count > 0 ?
1306 'Focus on quick wins first for immediate impact' :
1307 'Focus on content optimization for long-term growth'
1308 ];
1309 }
1310
1311 /**
1312 * Clear intelligence cache
1313 *
1314 * @return array Clear result
1315 */
1316 public function clear_intelligence_cache(): array {
1317 $intelligence_cache_keys = [
1318 'seo_insights_7d',
1319 'seo_insights_30d',
1320 'seo_insights_90d',
1321 'intelligent_dashboard_7d',
1322 'intelligent_dashboard_30d',
1323 'intelligent_dashboard_90d',
1324 'intelligent_opportunities_7d',
1325 'intelligent_opportunities_30d',
1326 'intelligent_opportunities_90d'
1327 ];
1328
1329 $cleared = 0;
1330 foreach ($intelligence_cache_keys as $key) {
1331 if (delete_transient($key)) {
1332 $cleared++;
1333 }
1334 }
1335
1336 return [
1337 'success' => true,
1338 'message' => "Cleared {$cleared} intelligence cache entries",
1339 'cleared_count' => $cleared,
1340 'timestamp' => current_time('mysql')
1341 ];
1342 }
1343 }
1344