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

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