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

1,342 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 public function get_dashboard_data(string $date_range = '30d'): array {
508 $cache_key = "analytics_dashboard_v5_{$date_range}";
509 $cached_data = get_transient($cache_key);
510
511 if ($cached_data !== false) {
512 // Core Web Vitals are cached separately with a much shorter
513 // lifetime than the GSC data (and failures are never cached), so
514 // a transient PageSpeed failure can't blank the CWV card for the
515 // dashboard cache's full 1-3 day TTL.
516 $cached_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
517 return $cached_data;
518 }
519
520 $dashboard_data = [
521 'traffic' => [],
522 'search_performance' => [],
523 'core_web_vitals' => [],
524 'last_updated' => current_time('mysql'),
525 'date_range' => $date_range
526 ];
527
528 $retry_count = 0;
529 $max_retries = 1;
530
531 while ($retry_count <= $max_retries) {
532 try {
533 // Ensure clients are initialized (lazy load) before any of
534 // them are used — this also builds the GA4 client when a
535 // property is configured.
536 if (!$this->search_console_client || !$this->search_analytics_client) {
537 $this->initialize_clients();
538 }
539
540 // Get Google Analytics traffic data. GA is optional — an
541 // isolated failure (misconfigured property, missing scope)
542 // must not abort the Search Console portion of the dashboard.
543 // 401s are re-thrown so the token-refresh retry below runs.
544 if ($this->analytics_client) {
545 try {
546 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
547 $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
548 $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
549 } catch (\Exception $ga_error) {
550 if ($ga_error->getCode() === 401) {
551 throw $ga_error;
552 }
553 $dashboard_data['traffic'] = [];
554 $dashboard_data['traffic_error'] = $ga_error->getMessage();
555 }
556 }
557
558 // Get Search Console data
559 if ($this->search_console_client) {
560 $site_url = $this->get_setting('search_console_property', get_site_url());
561 // Get totals
562 $totals = $this->search_console_client->get_search_totals($site_url, $date_range);
563
564 // Get performance data (keywords) using new client
565 if ($this->search_analytics_client) {
566 // GSC data has a 2-day delay; use D-2 as end_date to match the GSC dashboard.
567 $days = (int) str_replace('d', '', $date_range);
568 $end_date = gmdate('Y-m-d', strtotime('-2 days'));
569 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date)));
570
571 $search_performance = $this->search_analytics_client->get_search_analytics_data(
572 $site_url,
573 $start_date,
574 $end_date,
575 ['query'],
576 1000
577 );
578 } else {
579 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
580 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
581 }
582
583 // Calculate position distribution
584 $position_distribution = [
585 'top_3' => 0,
586 '4_10' => 0,
587 '10_50' => 0,
588 '51_100' => 0
589 ];
590
591 foreach ($search_performance['rows'] ?? [] as $row) {
592 $position = $row['position'] ?? 0;
593 if ($position <= 3) {
594 $position_distribution['top_3']++;
595 } elseif ($position <= 10) {
596 $position_distribution['4_10']++;
597 } elseif ($position <= 50) {
598 $position_distribution['10_50']++;
599 } elseif ($position <= 100) {
600 $position_distribution['51_100']++;
601 }
602 }
603
604 $dashboard_data['search_performance'] = array_merge($search_performance, [
605 'totals' => $totals,
606 'position_distribution' => $position_distribution
607 ]);
608
609 $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
610 } // Closing Search Console block
611
612 // If successful, break loop
613 break;
614 } catch (\Exception $e) {
615 // Check for 401 error
616 if ($e->getCode() === 401 && $retry_count < $max_retries) {
617 $this->refresh_access_token(true); // Force refresh
618
619 // Re-initialize clients with new token
620 $this->initialize_clients();
621
622 $retry_count++;
623 continue;
624 }
625 $dashboard_data['error'] = $e->getMessage();
626 break;
627 }
628 }
629
630 // Add last updated timestamp
631 $dashboard_data['last_updated'] = current_time('mysql');
632
633 // Cache the results — but never cache an error payload, otherwise a
634 // transient failure (e.g. a Google API 401) would be served from the
635 // cache for the full TTL even after the underlying issue is fixed.
636 // Core Web Vitals are deliberately NOT part of this cache (see below).
637 if (empty($dashboard_data['error'])) {
638 set_transient($cache_key, $dashboard_data, $this->cache_duration);
639 }
640
641 // Merge Core Web Vitals from their own short-lived cache after the
642 // long-lived GSC payload has been stored.
643 $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
644
645 return $dashboard_data;
646 }
647
648 /**
649 * Get Core Web Vitals for the analytics dashboard, cached independently
650 * of the dashboard payload.
651 *
652 * Successful results are cached for 1 hour; failures are never cached
653 * here (the PageSpeed client itself remembers failures for a few minutes
654 * to avoid re-blocking requests on a broken URL), so CWV recovers as soon
655 * as PageSpeed does instead of staying empty for the dashboard cache's
656 * 1-3 day TTL.
657 *
658 * @return array Core Web Vitals data, or an error payload
659 */
660 private function get_dashboard_core_web_vitals(): array {
661 $cached = get_transient('thinkrank_dashboard_cwv');
662 if (is_array($cached)) {
663 return $cached;
664 }
665
666 if (!$this->pagespeed_client) {
667 $this->initialize_clients();
668 }
669
670 if (!$this->pagespeed_client) {
671 return [];
672 }
673
674 try {
675 $core_web_vitals = $this->pagespeed_client->get_core_web_vitals(get_site_url());
676 set_transient('thinkrank_dashboard_cwv', $core_web_vitals, HOUR_IN_SECONDS);
677 return $core_web_vitals;
678 } catch (\Exception $psi_error) {
679 return [
680 'error' => $psi_error->getMessage(),
681 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
682 ];
683 }
684 }
685
686 /**
687 * Get SEO opportunities using Search Console data
688 *
689 * @param string $date_range Date range for analysis
690 * @return array SEO opportunities
691 */
692 public function get_seo_opportunities(string $date_range = '30d'): array {
693 $cache_key = "seo_opportunities_{$date_range}";
694 $cached_data = get_transient($cache_key);
695
696 if ($cached_data !== false) {
697 return $cached_data;
698 }
699
700 $opportunities = [
701 'keyword_opportunities' => [],
702 'page_opportunities' => [],
703 'device_insights' => [],
704 'last_updated' => current_time('mysql')
705 ];
706
707 $retry_count = 0;
708 $max_retries = 1;
709
710 while ($retry_count <= $max_retries) {
711 try {
712 if ($this->search_console_client) {
713 $site_url = $this->get_setting('search_console_property', get_site_url());
714
715 // Get keyword opportunities
716 $opportunities['keyword_opportunities'] = $this->search_console_client->get_keyword_opportunities($site_url, $date_range);
717
718 // Get device performance insights
719 $opportunities['device_insights'] = $this->search_console_client->get_device_performance($site_url, $date_range);
720
721 // Get search appearance data
722 $opportunities['search_appearance'] = $this->search_console_client->get_search_appearance($site_url, $date_range);
723 }
724
725 // If successful, break loop
726 break;
727 } catch (\Exception $e) {
728 // Check for 401 error
729 if ($e->getCode() === 401 && $retry_count < $max_retries) {
730 $this->refresh_access_token(true); // Force refresh
731
732 // Re-initialize clients with new token
733 $this->initialize_clients();
734
735 $retry_count++;
736 continue;
737 }
738
739 $opportunities['error'] = $e->getMessage();
740 break;
741 }
742 }
743
744 // Cache the results — but never cache an error payload (see
745 // get_dashboard_data() for rationale).
746 if (empty($opportunities['error'])) {
747 set_transient($cache_key, $opportunities, $this->cache_duration);
748 }
749
750 return $opportunities;
751 }
752
753 /**
754 * Memoized merge of the two settings categories this manager reads from.
755 * Rebuilt when settings are updated through update_settings() below.
756 *
757 * @var array|null
758 */
759 private ?array $merged_settings = null;
760
761 private function get_setting(string $key, $default = '') {
762 if ($this->merged_settings === null) {
763 // Merge settings to allow access to both categories. Memoized:
764 // this getter is called many times per request and each category
765 // read decrypts every sensitive option again.
766 $this->merged_settings = array_merge(
767 $this->settings_manager->get_settings('integrations'),
768 $this->settings_manager->get_settings('seo_analytics')
769 );
770 }
771
772 return $this->merged_settings[$key] ?? $default;
773 }
774
775 /**
776 * One-click setup for Google Search Console verification
777 * Following ThinkRank setup patterns
778 *
779 * @param string $site_url Site URL to verify
780 * @return array Setup results
781 */
782 public function setup_search_console_verification(string $site_url): array {
783 try {
784 if (!$this->search_console_client) {
785 return [
786 'success' => false,
787 'message' => 'Search Console API key not configured'
788 ];
789 }
790
791 $verification_result = $this->search_console_client->verify_site($site_url);
792
793 if ($verification_result['success']) {
794 // Update settings with verified site URL
795 $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
796 $this->merged_settings = null;
797 }
798
799 return $verification_result;
800 } catch (\Exception $e) {
801 return [
802 'success' => false,
803 'message' => $e->getMessage()
804 ];
805 }
806 }
807
808 /**
809 * Get site indexing status
810 *
811 * @return array Indexing status data
812 */
813 public function get_indexing_status(): array {
814 $cache_key = 'indexing_status';
815 $cached_data = get_transient($cache_key);
816
817 if ($cached_data !== false) {
818 return $cached_data;
819 }
820
821 $indexing_data = [
822 'status' => 'unknown',
823 'last_updated' => current_time('mysql')
824 ];
825
826 try {
827 if ($this->search_console_client) {
828 $site_url = $this->get_setting('search_console_property', get_site_url());
829 $indexing_data = $this->search_console_client->get_indexing_status($site_url);
830 }
831 } catch (\Exception $e) {
832 $indexing_data['error'] = $e->getMessage();
833 }
834
835 // Cache successful results for 1 hour. Errors are never cached, so a
836 // transient Google failure isn't served as "no data" for a full hour.
837 if (empty($indexing_data['error'])) {
838 set_transient($cache_key, $indexing_data, 3600);
839 }
840
841 return $indexing_data;
842 }
843
844 /**
845 * Force refresh of all cached data
846 *
847 * @return array Refresh results
848 */
849 public function refresh_data(): array {
850 // Clear all analytics-related transients, including the previous-period
851 // ranges used for trend comparison (14d/60d/180d) and the separately
852 // cached Core Web Vitals payload.
853 $cache_keys = [
854 'analytics_dashboard_v5_7d',
855 'analytics_dashboard_v5_30d',
856 'analytics_dashboard_v5_90d',
857 'analytics_dashboard_v5_14d',
858 'analytics_dashboard_v5_60d',
859 'analytics_dashboard_v5_180d',
860 'seo_opportunities_7d',
861 'seo_opportunities_30d',
862 'seo_opportunities_90d',
863 'seo_insights_7d',
864 'seo_insights_30d',
865 'seo_insights_90d',
866 'indexing_status',
867 'thinkrank_dashboard_cwv'
868 ];
869
870 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
871 // URL + device, so compute them for the URL/device combinations the
872 // plugin actually tests.
873 foreach (array_unique([home_url(), get_site_url()]) as $url) {
874 foreach (['mobile', 'desktop'] as $device) {
875 $psi_hash = md5($url . '|' . $device);
876 $legacy_hash = md5($url . '_' . $device);
877 $cache_keys[] = 'thinkrank_psi_snapshot_' . $psi_hash;
878 $cache_keys[] = 'thinkrank_psi_failure_' . $psi_hash;
879 $cache_keys[] = 'thinkrank_core_web_vitals_' . $legacy_hash;
880 $cache_keys[] = 'thinkrank_opportunities_' . $legacy_hash;
881 $cache_keys[] = 'thinkrank_diagnostics_' . $legacy_hash;
882 }
883 }
884
885 $cleared = 0;
886 foreach ($cache_keys as $key) {
887 if (delete_transient($key)) {
888 $cleared++;
889 }
890 }
891
892 return [
893 'success' => true,
894 'message' => "Cleared {$cleared} cached data entries",
895 'cleared_count' => $cleared,
896 'timestamp' => current_time('mysql')
897 ];
898 }
899
900 /**
901 * Get client status for debugging
902 *
903 * @return array Client status information
904 */
905 public function get_client_status(): array {
906 return [
907 'google_analytics' => [
908 'initialized' => !is_null($this->analytics_client),
909 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
910 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
911 ],
912 'search_console' => [
913 'initialized' => !is_null($this->search_console_client),
914 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
915 'site_url_configured' => !empty($this->get_setting('search_console_property'))
916 ],
917 'pagespeed' => [
918 'initialized' => !is_null($this->pagespeed_client),
919 'api_key_configured' => !empty($this->get_setting('google_pagespeed_api_key'))
920 ],
921 'cache_duration' => $this->cache_duration,
922 'last_checked' => current_time('mysql')
923 ];
924 }
925
926 /**
927 * Cleanup expired cache data
928 * Following ThinkRank cache cleanup patterns
929 *
930 * @return void
931 */
932 public function cleanup_cache(): void {
933 // WordPress handles transient cleanup automatically
934 // This method is for future custom cache cleanup if needed
935 }
936
937 // ========================================
938 // SEO Intelligence Enhancement Methods
939 // ========================================
940
941 /**
942 * Get intelligent dashboard data with trends and insights
943 *
944 * @param string $date_range Date range for analysis
945 * @return array Enhanced dashboard data with intelligence
946 */
947 public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
948 // Get base dashboard data
949 $dashboard_data = $this->get_dashboard_data($date_range);
950
951 // Check if there's an error in the data
952 if (isset($dashboard_data['error'])) {
953 return [
954 'success' => false,
955 'data' => null,
956 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
957 'timestamp' => current_time('mysql')
958 ];
959 }
960
961 // Check if we have real data available
962 if (!$this->has_real_data($dashboard_data)) {
963 return [
964 'success' => false,
965 'data' => null,
966 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
967 'timestamp' => current_time('mysql')
968 ];
969 }
970
971 // Intelligence engine is a Pro-only feature. The four SEO_* classes ship
972 // in Free too (the PSR-4 autoloader would resolve them), so class_exists()
973 // can't gate this — check the real Pro signal instead.
974 if (!Plan_Config::is_pro()) {
975 return [
976 'success' => false,
977 'data' => null,
978 'message' => 'Intelligent dashboard requires ThinkRank Pro.',
979 'timestamp' => current_time('mysql')
980 ];
981 }
982
983 $trend_analyzer = new SEO_Trend_Analyzer();
984 $scoring_engine = new SEO_Scoring_Engine();
985 $insight_generator = new SEO_Insight_Generator();
986
987 $data = $dashboard_data;
988
989 // Generate trend analysis
990 $current_data = $data;
991 $historical_data = $this->get_historical_data($date_range);
992
993 $trends = [
994 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
995 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
996 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
997 ];
998
999 // Calculate SEO health score
1000 $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
1001
1002 // Generate insights
1003 $insights = [
1004 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
1005 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
1006 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
1007 ];
1008
1009 // Combine all intelligence data
1010 $enhanced_data = array_merge($data, [
1011 'intelligence' => [
1012 'trends' => $trends,
1013 'seo_health_score' => $seo_health,
1014 'insights' => $insights,
1015 'last_analyzed' => current_time('mysql')
1016 ]
1017 ]);
1018
1019 return [
1020 'success' => true,
1021 'data' => $enhanced_data,
1022 'message' => 'Intelligent dashboard data retrieved successfully'
1023 ];
1024 }
1025
1026 /**
1027 * Get intelligent SEO opportunities with prioritization
1028 *
1029 * @param string $date_range Date range for analysis
1030 * @return array Enhanced opportunities with intelligence
1031 */
1032 public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
1033 // Get base opportunities data
1034 $opportunities_data = $this->get_seo_opportunities($date_range);
1035
1036 // Check if there's an error in the data
1037 if (isset($opportunities_data['error'])) {
1038 return [
1039 'success' => false,
1040 'data' => null,
1041 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
1042 'timestamp' => current_time('mysql')
1043 ];
1044 }
1045
1046 // The opportunities payload itself has no search_performance key — that
1047 // data lives in the (cached) dashboard payload. Pull it from there both
1048 // for the availability check and as input for the opportunity detectors;
1049 // checking $opportunities_data['search_performance'] here used to make
1050 // this method always bail with "No Search Console data available".
1051 $dashboard_data = $this->get_dashboard_data($date_range);
1052 $search_performance = $dashboard_data['search_performance'] ?? [];
1053 $opportunities_data['search_performance'] = $search_performance;
1054
1055 $has_search_data = !empty($search_performance['rows']) ||
1056 ($search_performance['total_clicks'] ?? 0) > 0 ||
1057 ($search_performance['total_impressions'] ?? 0) > 0;
1058
1059 if (!$has_search_data) {
1060 return [
1061 'success' => false,
1062 'data' => null,
1063 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
1064 'timestamp' => current_time('mysql')
1065 ];
1066 }
1067
1068 // Intelligence engine is a Pro-only feature — gate on the real Pro signal,
1069 // not class_exists() (the classes ship in Free and would autoload).
1070 if (!Plan_Config::is_pro()) {
1071 return [
1072 'success' => false,
1073 'data' => null,
1074 'message' => 'Intelligent opportunities require ThinkRank Pro.',
1075 'timestamp' => current_time('mysql')
1076 ];
1077 }
1078
1079 $opportunity_detector = new SEO_Opportunity_Detector();
1080 $scoring_engine = new SEO_Scoring_Engine();
1081
1082 $data = $opportunities_data;
1083
1084 // Detect intelligent opportunities
1085 $search_console_data = $data['search_performance'] ?? [];
1086 $analytics_data = $data;
1087
1088 $intelligent_opportunities = [
1089 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
1090 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
1091 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
1092 ];
1093
1094 // Prioritize all opportunities. prioritize_opportunities() expects
1095 // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list.
1096 $opportunities_by_category = [
1097 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
1098 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
1099 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [],
1100 ];
1101 $all_opportunities = array_merge(...array_values($opportunities_by_category));
1102
1103 $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category);
1104 $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
1105
1106 // Enhance original data with intelligence
1107 $enhanced_data = array_merge($data, [
1108 'intelligent_opportunities' => $intelligent_opportunities,
1109 'prioritized_opportunities' => $prioritized,
1110 'impact_effort_matrix' => $impact_matrix,
1111 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
1112 'last_analyzed' => current_time('mysql')
1113 ]);
1114
1115 return [
1116 'success' => true,
1117 'data' => $enhanced_data,
1118 'message' => 'Intelligent SEO opportunities retrieved successfully'
1119 ];
1120 }
1121
1122 /**
1123 * Get SEO performance insights
1124 *
1125 * @param string $date_range Date range for analysis
1126 * @return array SEO insights data
1127 */
1128 public function get_seo_insights(string $date_range = '30d'): array {
1129 $cache_key = "seo_insights_{$date_range}";
1130 $cached_data = get_transient($cache_key);
1131
1132 if ($cached_data !== false) {
1133 return [
1134 'success' => true,
1135 'data' => $cached_data,
1136 'cached' => true,
1137 'message' => 'SEO insights retrieved from cache'
1138 ];
1139 }
1140
1141 try {
1142 // Get dashboard data for analysis
1143 $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
1144
1145 if (!$dashboard_result['success']) {
1146 return $dashboard_result;
1147 }
1148
1149 $dashboard_data = $dashboard_result['data'];
1150 $intelligence = $dashboard_data['intelligence'] ?? [];
1151
1152 // Insights are a Pro-only feature — gate on the real Pro signal,
1153 // not class_exists() (SEO_Insight_Generator ships in Free too).
1154 if (!Plan_Config::is_pro()) {
1155 return [
1156 'success' => false,
1157 'data' => null,
1158 'message' => 'SEO insights require ThinkRank Pro.',
1159 'timestamp' => current_time('mysql')
1160 ];
1161 }
1162
1163 $insight_generator = new SEO_Insight_Generator();
1164
1165 // Collect all insights
1166 $all_insights = [];
1167
1168 if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
1169 $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
1170 }
1171
1172 if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
1173 $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
1174 }
1175
1176 if (!empty($intelligence['insights']['content_insights']['insights'])) {
1177 $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
1178 }
1179
1180 // Format and prioritize insights
1181 $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
1182 $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
1183
1184 $insights_data = [
1185 'insights' => $prioritized_insights['prioritized_insights'],
1186 'summary' => [
1187 'total_insights' => count($formatted_insights),
1188 'high_impact_count' => $prioritized_insights['high_impact_count'],
1189 'action_required_count' => $prioritized_insights['action_required_count']
1190 ],
1191 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
1192 'generated_at' => current_time('mysql')
1193 ];
1194
1195 // Cache the results
1196 set_transient($cache_key, $insights_data, $this->cache_duration);
1197
1198 return [
1199 'success' => true,
1200 'data' => $insights_data,
1201 'cached' => false,
1202 'message' => 'SEO insights generated successfully'
1203 ];
1204 } catch (\Exception $e) {
1205 return [
1206 'success' => false,
1207 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
1208 'data' => null
1209 ];
1210 }
1211 }
1212
1213 /**
1214 * Check if real analytics data is available
1215 *
1216 * @param array $dashboard_data Dashboard data to check
1217 * @return bool True if real data is available
1218 */
1219 private function has_real_data(array $dashboard_data): bool {
1220 // Check if we have meaningful traffic data
1221 $traffic = $dashboard_data['traffic'] ?? [];
1222 $search_performance = $dashboard_data['search_performance'] ?? [];
1223
1224 $has_traffic = !empty($traffic) && (
1225 ($traffic['sessions'] ?? 0) > 0 ||
1226 ($traffic['pageviews'] ?? 0) > 0 ||
1227 ($traffic['active_users'] ?? 0) > 0
1228 );
1229
1230 $has_search_data = !empty($search_performance) && (
1231 !empty($search_performance['rows']) ||
1232 ($search_performance['total_clicks'] ?? 0) > 0 ||
1233 ($search_performance['total_impressions'] ?? 0) > 0
1234 );
1235
1236 return $has_traffic || $has_search_data;
1237 }
1238
1239 /**
1240 * Get historical data for trend comparison
1241 *
1242 * @param string $current_range Current date range
1243 * @return array Historical data
1244 */
1245 private function get_historical_data(string $current_range): array {
1246 // Calculate previous period based on current range
1247 $previous_range = $this->calculate_previous_period($current_range);
1248
1249 // Try to get actual historical data from previous period
1250 $historical_data = $this->get_dashboard_data($previous_range);
1251
1252 // Return the actual historical data (may be empty if no real data available)
1253 return [
1254 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
1255 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
1256 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
1257 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
1258 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
1259 ];
1260 }
1261
1262 /**
1263 * Calculate previous period for comparison
1264 *
1265 * @param string $current_range Current range
1266 * @return string Previous period range
1267 */
1268 private function calculate_previous_period(string $current_range): string {
1269 // Simple mapping for now - could be enhanced with actual date calculations
1270 $period_mapping = [
1271 '7d' => '14d',
1272 '30d' => '60d',
1273 '90d' => '180d'
1274 ];
1275
1276 return $period_mapping[$current_range] ?? '60d';
1277 }
1278
1279 /**
1280 * Generate opportunity summary
1281 *
1282 * @param array $opportunities All opportunities
1283 * @return array Opportunity summary
1284 */
1285 private function generate_opportunity_summary(array $opportunities): array {
1286 $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
1287 $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
1288 $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
1289
1290 $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
1291
1292 $potential_clicks = 0;
1293 if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
1294 $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
1295 }
1296
1297 return [
1298 'total_opportunities' => $total_opportunities,
1299 'quick_wins_count' => $quick_wins_count,
1300 'content_opportunities_count' => $content_opportunities_count,
1301 'keyword_opportunities_count' => $keyword_opportunities_count,
1302 'potential_additional_clicks' => $potential_clicks,
1303 'priority_recommendation' => $quick_wins_count > 0 ?
1304 'Focus on quick wins first for immediate impact' :
1305 'Focus on content optimization for long-term growth'
1306 ];
1307 }
1308
1309 /**
1310 * Clear intelligence cache
1311 *
1312 * @return array Clear result
1313 */
1314 public function clear_intelligence_cache(): array {
1315 $intelligence_cache_keys = [
1316 'seo_insights_7d',
1317 'seo_insights_30d',
1318 'seo_insights_90d',
1319 'intelligent_dashboard_7d',
1320 'intelligent_dashboard_30d',
1321 'intelligent_dashboard_90d',
1322 'intelligent_opportunities_7d',
1323 'intelligent_opportunities_30d',
1324 'intelligent_opportunities_90d'
1325 ];
1326
1327 $cleared = 0;
1328 foreach ($intelligence_cache_keys as $key) {
1329 if (delete_transient($key)) {
1330 $cleared++;
1331 }
1332 }
1333
1334 return [
1335 'success' => true,
1336 'message' => "Cleared {$cleared} intelligence cache entries",
1337 'cleared_count' => $cleared,
1338 'timestamp' => current_time('mysql')
1339 ];
1340 }
1341 }
1342