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

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