PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.8.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 All 49 releases
← All changes | includes/seo/class-analytics-manager.php +347 -519 1.10.02.7.0 View file →
@@ -20,8 +20,9 @@
20 20 use ThinkRank\Integrations\Google_Analytics_Client;
21 21 use ThinkRank\Integrations\Google_Search_Console_Client;
22 22 use ThinkRank\Integrations\Google_PageSpeed_Client;
23 23 use ThinkRank\Integrations\Google_Search_Analytics_Client;
24 +use ThinkRank\Integrations\Google_OAuth_Proxy;
24 25
25 26 // Prevent direct access
26 27 if (!defined('ABSPATH')) {
27 28 exit;
@@ -37,14 +38,8 @@
37 38 */
38 39 class Analytics_Manager {
39 40
40 41 /**
41 - * Google Client ID
42 - * @var string
43 - */
44 - private const THINKRANK_GOOGLE_CLIENT_ID = '435184728932-8urjh1ah43490lu32se135o4mj123s84.apps.googleusercontent.com';
45 -
46 - /**
47 42 * Settings Manager instance
48 43 *
49 44 * @var Settings_Manager
50 45 */
@@ -92,8 +87,45 @@
92 87 */
93 88 private static bool $token_refreshed_this_request = false;
94 89
95 90 /**
91 + * Single-flight lock for the OAuth refresh exchange.
92 + *
93 + * @var string
94 + */
95 + private const REFRESH_LOCK = 'thinkrank_token_refresh_lock';
96 +
97 + /**
98 + * How long a held refresh lock stays valid. Longer than the request
99 + * timeout below, so a request that dies mid-exchange still frees it.
100 + *
101 + * @var int
102 + */
103 + private const REFRESH_LOCK_TTL = 60;
104 +
105 + /**
106 + * Set after a failed exchange; suppresses retries until it expires.
107 + *
108 + * @var string
109 + */
110 + private const REFRESH_BACKOFF = 'thinkrank_token_refresh_backoff';
111 +
112 + /**
113 + * How long to stay quiet after a failed exchange.
114 + *
115 + * @var int
116 + */
117 + private const REFRESH_BACKOFF_TTL = 300;
118 +
119 + /**
120 + * Timeout for the refresh exchange. A healthy proxy answers in ~1s; the
121 + * old 30s meant one outage held a request open for half a minute.
122 + *
123 + * @var int
124 + */
125 + private const REFRESH_TIMEOUT = 10;
126 +
127 + /**
96 128 * Constructor
97 129 *
98 130 * @param Settings_Manager|null $settings_manager Settings manager instance
99 131 */
@@ -100,11 +132,8 @@
100 132 public function __construct(?Settings_Manager $settings_manager = null) {
101 133 $this->settings_manager = $settings_manager ?? new Settings_Manager();
102 134 // Pro: daily refresh (86400s), Free: 3-day refresh (259200s)
103 135 $this->cache_duration = defined('THINKRANK_PRO_VERSION') ? 86400 : 259200;
104 -
105 - // Clear any existing cached insights to ensure new logic takes effect
106 - $this->clear_insights_cache();
107 136 }
108 137
109 138 /**
110 139 * Initialize Analytics Manager
@@ -115,10 +144,11 @@
115 144 public function init(): void {
116 145 // Register custom cron interval (45 minutes)
117 146 add_filter('cron_schedules', [$this, 'add_cron_intervals']);
118 147
119 - // Initialize Google API clients
120 - add_action('init', [$this, 'initialize_clients']);
148 + // Initialize Google API clients — but only in the contexts that can use
149 + // them. See maybe_initialize_clients().
150 + add_action('init', [$this, 'maybe_initialize_clients']);
121 151
122 152 // Initialize token refresh scheduling
123 153 add_action('init', [$this, 'init_token_refresh']);
124 154
@@ -138,11 +168,16 @@
138 168 * @param array $schedules Existing cron schedules
139 169 * @return array Modified cron schedules
140 170 */
141 171 public function add_cron_intervals(array $schedules): array {
172 + // Only translate once `init` has run: wp_get_schedules() can be reached
173 + // before then (wp_schedule_event() at plugin boot does), and translating
174 + // that early trips the _load_textdomain_just_in_time notice on WP 6.7+.
142 175 $schedules['thinkrank_45min'] = [
143 176 'interval' => 2700, // 45 minutes in seconds
144 - 'display' => __('Every 45 Minutes', 'thinkrank')
177 + 'display' => did_action('init')
178 + ? __('Every 45 Minutes', 'thinkrank')
179 + : 'Every 45 Minutes'
145 180 ];
146 181 return $schedules;
147 182 }
148 183
@@ -179,8 +214,43 @@
179 214 return $this->get_setting('search_console_property', get_site_url());
180 215 }
181 216
182 217 /**
218 + * Initialize the Google clients on `init`, in the contexts that use them.
219 + *
220 + * initialize_clients() refreshes the OAuth token, which is a blocking
221 + * outbound POST to the OAuth proxy. Hooked unconditionally it ran on every
222 + * anonymous front-end request, so a proxy outage became a site-wide TTFB
223 + * collapse — with each visitor waiting for the network call, and none of
224 + * them able to use a Google client anyway. No front-end code path reads
225 + * one: every consumer is a REST endpoint, a cron callback or WP-CLI, and
226 + * each either calls initialize_clients() itself or goes through
227 + * get_search_console_client(), which initializes lazily (#383).
228 + *
229 + * @since 2.0.1
230 + * @return void
231 + */
232 + public function maybe_initialize_clients(): void {
233 + $wanted = is_admin()
234 + || wp_doing_cron()
235 + || (defined('REST_REQUEST') && REST_REQUEST)
236 + || (defined('WP_CLI') && WP_CLI);
237 +
238 + /**
239 + * Filter whether the Google API clients are initialized for this request.
240 + *
241 + * @since 2.0.1
242 + *
243 + * @param bool $wanted Whether to initialize the clients.
244 + */
245 + if (!apply_filters('thinkrank_initialize_google_clients', $wanted)) {
246 + return;
247 + }
248 +
249 + $this->initialize_clients();
250 + }
251 +
252 + /**
183 253 * Initialize Google API clients
184 254 * Following AI_Manager client initialization pattern
185 255 *
186 256 * @return void
@@ -207,15 +277,32 @@
207 277 $timeout,
208 278 !empty($access_token) ? $access_token : null
209 279 );
210 280
211 - // Initialize PageSpeed client with OAuth token (same token as Search Console)
212 - if (!empty($access_token)) {
213 - $timeout = (int) $this->get_setting('api_timeout', 30);
214 - $this->pagespeed_client = new Google_PageSpeed_Client('', $timeout, $access_token);
281 + // Initialize PageSpeed client. PSI is a public API — it uses the
282 + // site's own API key (or keyless per-IP quota), never the shared
283 + // OAuth token, which would bill every install's Lighthouse runs
284 + // to one exhausted Google Cloud project (429 for everyone).
285 + // Shorter timeout here: the dashboard CWV card fetches in-request
286 + // on a cold cache and must not stall the whole dashboard payload.
287 + $this->pagespeed_client = Google_PageSpeed_Client::for_site(25);
288 +
289 + // Initialize Google Analytics (GA4) client when a property has
290 + // been selected. The GA settings UI stores the property in the
291 + // Admin API's "properties/XXXXXXXX" form, which is exactly what
292 + // the Data API endpoints expect.
293 + $ga_property = (string) $this->get_setting('seo_analytics_google_analytics_property_id');
294 + if (!empty($access_token) && $ga_property !== '') {
295 + if (strpos($ga_property, 'properties/') !== 0) {
296 + $ga_property = 'properties/' . $ga_property;
297 + }
298 + $this->analytics_client = new Google_Analytics_Client('', $ga_property, $timeout, $access_token);
215 299 }
216 300 } catch (\Exception $e) {
217 - error_log('ThinkRank Analytics Init Error: ' . $e->getMessage());
301 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
302 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
303 + error_log('ThinkRank Analytics Init Error: ' . $e->getMessage());
304 + }
218 305 }
219 306 }
220 307
221 308 /**
@@ -265,8 +352,9 @@
265 352 $this->settings_manager->update_settings([
266 353 'google_token_expires_in' => 3600
267 354 ], 'integrations');
268 355 }
356 + $this->merged_settings = null;
269 357 }
270 358 }
271 359
272 360 /**
@@ -359,22 +447,47 @@
359 447 // Calculate absolute expiration time (created + relative seconds)
360 448 $expiration_time = $created + $expires_in;
361 449
362 450 // Refresh if forced, expired, or expiring within 5 minutes (300 seconds)
363 - if ($force || $current_time >= ($expiration_time - 300)) {
451 + if (!$force && $current_time < ($expiration_time - 300)) {
452 + return;
453 + }
364 454
365 - $api_url = 'https://api.thinkrank.ai/v1/callback.php';
455 + // A failed exchange leaves google_token_created untouched, so the
456 + // expiry condition above stays true and the next request tries again.
457 + // Without a backoff a proxy outage means one blocking network call per
458 + // request, forever. A forced refresh — the user reconnecting — is a
459 + // deliberate act and skips the wait (#383).
460 + if (!$force && get_transient(self::REFRESH_BACKOFF)) {
461 + return;
462 + }
366 463
367 - $response = wp_remote_post($api_url, [
368 - 'body' => [
369 - 'type' => 'google_analytics',
464 + // One exchange at a time. Concurrent callers past the expiry threshold
465 + // would otherwise all refresh at once and invalidate each other's
466 + // in-flight grants; the losers fall through with the current token and
467 + // pick up the new one on their next read.
468 + if (!$force && !$this->acquire_refresh_lock()) {
469 + return;
470 + }
471 +
472 + try {
473 + // The proxy owns the Google app credentials; we only ever hand it
474 + // the refresh token and let it perform the exchange.
475 + $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [
476 + 'headers' => [
477 + 'Content-Type' => 'application/json',
478 + 'Accept' => 'application/json',
479 + ],
480 + 'body' => wp_json_encode([
481 + 'action' => 'refresh',
370 482 'refresh_token' => $refresh_token,
371 - 'client_id' => self::THINKRANK_GOOGLE_CLIENT_ID
372 - ],
373 - 'timeout' => 30
483 + 'site' => home_url(),
484 + ]),
485 + 'timeout' => self::REFRESH_TIMEOUT
374 486 ]);
375 487
376 488 if (is_wp_error($response)) {
489 + $this->back_off_refresh();
377 490 return;
378 491 }
379 492
380 493 $body = wp_remote_retrieve_body($response);
@@ -380,8 +493,22 @@
380 493 $body = wp_remote_retrieve_body($response);
381 494 $data = json_decode($body, true);
382 495
383 496 if (empty($data['access_token'])) {
497 + // invalid_grant is terminal: the user revoked access in their
498 + // Google account, or the refresh token was superseded by a
499 + // newer grant. Retrying can never succeed, so stop pretending
500 + // the site is connected — otherwise the UI shows "Connected"
501 + // while every API call 401s.
502 + if (($data['error'] ?? '') === 'invalid_grant') {
503 + Google_OAuth_Proxy::mark_revoked();
504 + return;
505 + }
506 +
507 + // Any other failure (network blip, proxy 502) is transient;
508 + // leave the credentials alone and let the next run retry —
509 + // after the backoff, not on the very next request.
510 + $this->back_off_refresh();
384 511 return;
385 512 }
386 513
387 514 // Update settings with new token data
@@ -396,12 +523,80 @@
396 523 $this->settings_manager->update_settings([
397 524 'google_refresh_token' => $data['refresh_token']
398 525 ], 'integrations');
399 526 }
527 +
528 + // A success clears any backoff a previous failure left behind.
529 + delete_transient(self::REFRESH_BACKOFF);
530 +
531 + // Drop the memoized settings merge so subsequent reads (e.g.
532 + // re-initializing clients) see the fresh token.
533 + $this->merged_settings = null;
534 + } finally {
535 + $this->release_refresh_lock();
400 536 }
401 537 }
402 538
403 539 /**
540 + * Take the single-flight lock for the refresh exchange.
541 + *
542 + * @since 2.0.1
543 + * @return bool True when this request holds the lock.
544 + */
545 + private function acquire_refresh_lock(): bool {
546 + // With a persistent object cache, add is atomic — memcached and Redis
547 + // both fail an ADD on an existing key — so exactly one caller wins.
548 + if (wp_using_ext_object_cache()) {
549 + return (bool) wp_cache_add(self::REFRESH_LOCK, time(), 'thinkrank', self::REFRESH_LOCK_TTL);
550 + }
551 +
552 + // Without one, the options table is the shared store, and the unique
553 + // index on option_name gives add_option() the same all-or-nothing
554 + // result. set_transient() would not: it is an update, so every
555 + // concurrent caller would "win".
556 + if (add_option(self::REFRESH_LOCK, time(), '', 'no')) {
557 + return true;
558 + }
559 +
560 + // Reclaim a lock whose holder died before releasing it.
561 + $held = (int) get_option(self::REFRESH_LOCK);
562 +
563 + if ($held > 0 && (time() - $held) > self::REFRESH_LOCK_TTL) {
564 + delete_option(self::REFRESH_LOCK);
565 +
566 + return (bool) add_option(self::REFRESH_LOCK, time(), '', 'no');
567 + }
568 +
569 + return false;
570 + }
571 +
572 + /**
573 + * Release the single-flight lock.
574 + *
575 + * @since 2.0.1
576 + * @return void
577 + */
578 + private function release_refresh_lock(): void {
579 + if (wp_using_ext_object_cache()) {
580 + wp_cache_delete(self::REFRESH_LOCK, 'thinkrank');
581 +
582 + return;
583 + }
584 +
585 + delete_option(self::REFRESH_LOCK);
586 + }
587 +
588 + /**
589 + * Stop retrying the exchange for a while after a failure.
590 + *
591 + * @since 2.0.1
592 + * @return void
593 + */
594 + private function back_off_refresh(): void {
595 + set_transient(self::REFRESH_BACKOFF, time(), self::REFRESH_BACKOFF_TTL);
596 + }
597 +
598 + /**
404 599 * Test all Google API connections
405 600 * Following ThinkRank test_connection patterns
406 601 *
407 602 * @return array Connection test results
@@ -472,14 +667,21 @@
472 667 * Combines data from all Google APIs with caching
473 668 *
474 669 * @param string $date_range Date range for data
475 670 * @return array Dashboard data
671 + *
672 + * @throws \Exception On failure.
476 673 */
477 674 public function get_dashboard_data(string $date_range = '30d'): array {
478 - $cache_key = "analytics_dashboard_v4_{$date_range}";
675 + $cache_key = "analytics_dashboard_v5_{$date_range}";
479 676 $cached_data = get_transient($cache_key);
480 677
481 678 if ($cached_data !== false) {
679 + // Core Web Vitals are cached separately with a much shorter
680 + // lifetime than the GSC data (and failures are never cached), so
681 + // a transient PageSpeed failure can't blank the CWV card for the
682 + // dashboard cache's full 1-3 day TTL.
683 + $cached_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
482 684 return $cached_data;
483 685 }
484 686
485 687 $dashboard_data = [
@@ -494,18 +696,29 @@
494 696 $max_retries = 1;
495 697
496 698 while ($retry_count <= $max_retries) {
497 699 try {
498 - // Get Google Analytics traffic data
499 - if ($this->analytics_client) {
500 - $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
501 - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
502 - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
700 + // Ensure clients are initialized (lazy load) before any of
701 + // them are used — this also builds the GA4 client when a
702 + // property is configured.
703 + if (!$this->search_console_client || !$this->search_analytics_client) {
704 + $this->initialize_clients();
503 705 }
504 706
505 - // Ensure clients are initialized (lazy load)
506 - if (!$this->search_console_client || !$this->search_analytics_client) {
507 - $this->initialize_clients();
707 + // Get Google Analytics traffic data. GA is optional — an
708 + // isolated failure (misconfigured property, missing scope)
709 + // must not abort the Search Console portion of the dashboard.
710 + // 401s are re-thrown so the token-refresh retry below runs.
711 + if ($this->analytics_client) {
712 + try {
713 + $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
714 + } catch (\Exception $ga_error) {
715 + if ($ga_error->getCode() === 401) {
716 + throw $ga_error;
717 + }
718 + $dashboard_data['traffic'] = [];
719 + $dashboard_data['traffic_error'] = $ga_error->getMessage();
720 + }
508 721 }
509 722
510 723 // Get Search Console data
511 724 if ($this->search_console_client) {
@@ -556,27 +769,10 @@
556 769 $dashboard_data['search_performance'] = array_merge($search_performance, [
557 770 'totals' => $totals,
558 771 'position_distribution' => $position_distribution
559 772 ]);
560 -
561 - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
562 773 } // Closing Search Console block
563 774
564 - // Get Core Web Vitals data (isolated try-catch so PageSpeed failures
565 - // don't abort the entire dashboard data collection)
566 - if ($this->pagespeed_client) {
567 - try {
568 - $site_url = get_site_url();
569 - $dashboard_data['core_web_vitals'] = $this->pagespeed_client->get_core_web_vitals($site_url);
570 - } catch (\Exception $psi_error) {
571 - // Log the PageSpeed error but don't fail the whole dashboard
572 - $dashboard_data['core_web_vitals'] = [
573 - 'error' => $psi_error->getMessage(),
574 - 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
575 - ];
576 - }
577 - }
578 -
579 775 // If successful, break loop
580 776 break;
581 777 } catch (\Exception $e) {
582 778 // Check for 401 error
@@ -596,15 +792,62 @@
596 792
597 793 // Add last updated timestamp
598 794 $dashboard_data['last_updated'] = current_time('mysql');
599 795
600 - // Cache the results
601 - set_transient($cache_key, $dashboard_data, $this->cache_duration);
796 + // Cache the results — but never cache an error payload, otherwise a
797 + // transient failure (e.g. a Google API 401) would be served from the
798 + // cache for the full TTL even after the underlying issue is fixed.
799 + // Core Web Vitals are deliberately NOT part of this cache (see below).
800 + if (empty($dashboard_data['error'])) {
801 + set_transient($cache_key, $dashboard_data, $this->cache_duration);
802 + }
602 803
804 + // Merge Core Web Vitals from their own short-lived cache after the
805 + // long-lived GSC payload has been stored.
806 + $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
807 +
603 808 return $dashboard_data;
604 809 }
605 810
606 811 /**
812 + * Get Core Web Vitals for the analytics dashboard, cached independently
813 + * of the dashboard payload.
814 + *
815 + * Successful results are cached for 1 hour; failures are never cached
816 + * here (the PageSpeed client itself remembers failures for a few minutes
817 + * to avoid re-blocking requests on a broken URL), so CWV recovers as soon
818 + * as PageSpeed does instead of staying empty for the dashboard cache's
819 + * 1-3 day TTL.
820 + *
821 + * @return array Core Web Vitals data, or an error payload
822 + */
823 + private function get_dashboard_core_web_vitals(): array {
824 + $cached = get_transient('thinkrank_dashboard_cwv');
825 + if (is_array($cached)) {
826 + return $cached;
827 + }
828 +
829 + if (!$this->pagespeed_client) {
830 + $this->initialize_clients();
831 + }
832 +
833 + if (!$this->pagespeed_client) {
834 + return [];
835 + }
836 +
837 + try {
838 + $core_web_vitals = $this->pagespeed_client->get_core_web_vitals(get_site_url());
839 + set_transient('thinkrank_dashboard_cwv', $core_web_vitals, HOUR_IN_SECONDS);
840 + return $core_web_vitals;
841 + } catch (\Exception $psi_error) {
842 + return [
843 + 'error' => $psi_error->getMessage(),
844 + 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
845 + ];
846 + }
847 + }
848 +
849 + /**
607 850 * Get SEO opportunities using Search Console data
608 851 *
609 852 * @param string $date_range Date range for analysis
610 853 * @return array SEO opportunities
@@ -660,22 +903,37 @@
660 903 break;
661 904 }
662 905 }
663 906
664 - // Cache the results
665 - set_transient($cache_key, $opportunities, $this->cache_duration);
907 + // Cache the results — but never cache an error payload (see
908 + // get_dashboard_data() for rationale).
909 + if (empty($opportunities['error'])) {
910 + set_transient($cache_key, $opportunities, $this->cache_duration);
911 + }
666 912
667 913 return $opportunities;
668 914 }
669 915
670 - private function get_setting(string $key, $default = '') {
671 - $integrations_settings = $this->settings_manager->get_settings('integrations');
672 - $analytics_settings = $this->settings_manager->get_settings('seo_analytics');
916 + /**
917 + * Memoized merge of the two settings categories this manager reads from.
918 + * Rebuilt when settings are updated through update_settings() below.
919 + *
920 + * @var array|null
921 + */
922 + private ?array $merged_settings = null;
673 923
674 - // Merge settings to allow access to both categories
675 - $all_settings = array_merge($integrations_settings, $analytics_settings);
924 + private function get_setting(string $key, $fallback = '') {
925 + if ($this->merged_settings === null) {
926 + // Merge settings to allow access to both categories. Memoized:
927 + // this getter is called many times per request and each category
928 + // read decrypts every sensitive option again.
929 + $this->merged_settings = array_merge(
930 + $this->settings_manager->get_settings('integrations'),
931 + $this->settings_manager->get_settings('seo_analytics')
932 + );
933 + }
676 934
677 - return $all_settings[$key] ?? $default;
935 + return $this->merged_settings[$key] ?? $fallback;
678 936 }
679 937
680 938 /**
681 939 * One-click setup for Google Search Console verification
@@ -697,8 +955,9 @@
697 955
698 956 if ($verification_result['success']) {
699 957 // Update settings with verified site URL
700 958 $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
959 + $this->merged_settings = null;
701 960 }
702 961
703 962 return $verification_result;
704 963 } catch (\Exception $e) {
@@ -709,60 +968,45 @@
709 968 }
710 969 }
711 970
712 971 /**
713 - * Get site indexing status
714 - *
715 - * @return array Indexing status data
716 - */
717 - public function get_indexing_status(): array {
718 - $cache_key = 'indexing_status';
719 - $cached_data = get_transient($cache_key);
720 -
721 - if ($cached_data !== false) {
722 - return $cached_data;
723 - }
724 -
725 - $indexing_data = [
726 - 'status' => 'unknown',
727 - 'last_updated' => current_time('mysql')
728 - ];
729 -
730 - try {
731 - if ($this->search_console_client) {
732 - $site_url = $this->get_setting('search_console_property', get_site_url());
733 - $indexing_data = $this->search_console_client->get_indexing_status($site_url);
734 - }
735 - } catch (\Exception $e) {
736 - $indexing_data['error'] = $e->getMessage();
737 - }
738 -
739 - // Cache for 1 hour
740 - set_transient($cache_key, $indexing_data, 3600);
741 -
742 - return $indexing_data;
743 - }
744 -
745 - /**
746 972 * Force refresh of all cached data
747 973 *
748 974 * @return array Refresh results
749 975 */
750 976 public function refresh_data(): array {
751 - // Clear all analytics-related transients
977 + // Clear all analytics-related transients, including the previous-period
978 + // ranges used for trend comparison (14d/60d/180d) and the separately
979 + // cached Core Web Vitals payload.
752 980 $cache_keys = [
753 - 'analytics_dashboard_v4_7d',
754 - 'analytics_dashboard_v4_30d',
755 - 'analytics_dashboard_v4_90d',
981 + 'analytics_dashboard_v5_7d',
982 + 'analytics_dashboard_v5_30d',
983 + 'analytics_dashboard_v5_90d',
984 + 'analytics_dashboard_v5_14d',
985 + 'analytics_dashboard_v5_60d',
986 + 'analytics_dashboard_v5_180d',
756 987 'seo_opportunities_7d',
757 988 'seo_opportunities_30d',
758 989 'seo_opportunities_90d',
759 - 'seo_insights_7d',
760 - 'seo_insights_30d',
761 - 'seo_insights_90d',
762 - 'indexing_status'
990 + 'indexing_status',
991 + 'thinkrank_dashboard_cwv'
763 992 ];
764 993
994 + // Also clear PageSpeed-derived caches. Their keys are md5-derived from
995 + // URL + device, so compute them for the URL/device combinations the
996 + // plugin actually tests.
997 + foreach (array_unique([home_url(), get_site_url()]) as $url) {
998 + foreach (['mobile', 'desktop'] as $device) {
999 + $psi_hash = md5($url . '|' . $device);
1000 + $legacy_hash = md5($url . '_' . $device);
1001 + $cache_keys[] = 'thinkrank_psi_snapshot_' . $psi_hash;
1002 + $cache_keys[] = 'thinkrank_psi_failure_' . $psi_hash;
1003 + $cache_keys[] = 'thinkrank_core_web_vitals_' . $legacy_hash;
1004 + $cache_keys[] = 'thinkrank_opportunities_' . $legacy_hash;
1005 + $cache_keys[] = 'thinkrank_diagnostics_' . $legacy_hash;
1006 + }
1007 + }
1008 +
765 1009 $cleared = 0;
766 1010 foreach ($cache_keys as $key) {
767 1011 if (delete_transient($key)) {
768 1012 $cleared++;
@@ -777,25 +1021,8 @@
777 1021 ];
778 1022 }
779 1023
780 1024 /**
781 - * Clear insights cache specifically
782 - *
783 - * @return void
784 - */
785 - private function clear_insights_cache(): void {
786 - $insight_cache_keys = [
787 - 'seo_insights_7d',
788 - 'seo_insights_30d',
789 - 'seo_insights_90d'
790 - ];
791 -
792 - foreach ($insight_cache_keys as $key) {
793 - delete_transient($key);
794 - }
795 - }
796 -
797 - /**
798 1025 * Get client status for debugging
799 1026 *
800 1027 * @return array Client status information
801 1028 */
@@ -803,9 +1030,9 @@
803 1030 return [
804 1031 'google_analytics' => [
805 1032 'initialized' => !is_null($this->analytics_client),
806 1033 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
807 - 'property_id_configured' => !empty($this->get_setting('google_analytics_property_id'))
1034 + 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
808 1035 ],
809 1036 'search_console' => [
810 1037 'initialized' => !is_null($this->search_console_client),
811 1038 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
@@ -828,405 +1055,6 @@
828 1055 */
829 1056 public function cleanup_cache(): void {
830 1057 // WordPress handles transient cleanup automatically
831 1058 // This method is for future custom cache cleanup if needed
832 - }
833 -
834 - // ========================================
835 - // SEO Intelligence Enhancement Methods
836 - // ========================================
837 -
838 - /**
839 - * Get intelligent dashboard data with trends and insights
840 - *
841 - * @param string $date_range Date range for analysis
842 - * @return array Enhanced dashboard data with intelligence
843 - */
844 - public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
845 - // Get base dashboard data
846 - $dashboard_data = $this->get_dashboard_data($date_range);
847 -
848 - // Check if there's an error in the data
849 - if (isset($dashboard_data['error'])) {
850 - return [
851 - 'success' => false,
852 - 'data' => null,
853 - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
854 - 'timestamp' => current_time('mysql')
855 - ];
856 - }
857 -
858 - // Check if we have real data available
859 - if (!$this->has_real_data($dashboard_data)) {
860 - return [
861 - 'success' => false,
862 - 'data' => null,
863 - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
864 - 'timestamp' => current_time('mysql')
865 - ];
866 - }
867 -
868 - // Initialize intelligence classes — only available in Pro
869 - if (
870 - !class_exists('ThinkRank\SEO\SEO_Trend_Analyzer') ||
871 - !class_exists('ThinkRank\SEO\SEO_Scoring_Engine') ||
872 - !class_exists('ThinkRank\SEO\SEO_Insight_Generator')
873 - ) {
874 - return [
875 - 'success' => false,
876 - 'data' => null,
877 - 'message' => 'Intelligent dashboard requires ThinkRank Pro.',
878 - 'timestamp' => current_time('mysql')
879 - ];
880 - }
881 -
882 - $trend_analyzer = new SEO_Trend_Analyzer();
883 - $scoring_engine = new SEO_Scoring_Engine();
884 - $insight_generator = new SEO_Insight_Generator();
885 -
886 - $data = $dashboard_data;
887 -
888 - // Generate trend analysis
889 - $current_data = $data;
890 - $historical_data = $this->get_historical_data($date_range);
891 -
892 - $trends = [
893 - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
894 - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
895 - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
896 - ];
897 -
898 - // Calculate SEO health score
899 - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
900 -
901 - // Generate insights
902 - $insights = [
903 - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
904 - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
905 - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
906 - ];
907 -
908 - // Combine all intelligence data
909 - $enhanced_data = array_merge($data, [
910 - 'intelligence' => [
911 - 'trends' => $trends,
912 - 'seo_health_score' => $seo_health,
913 - 'insights' => $insights,
914 - 'last_analyzed' => current_time('mysql')
915 - ]
916 - ]);
917 -
918 - return [
919 - 'success' => true,
920 - 'data' => $enhanced_data,
921 - 'message' => 'Intelligent dashboard data retrieved successfully'
922 - ];
923 - }
924 -
925 - /**
926 - * Get intelligent SEO opportunities with prioritization
927 - *
928 - * @param string $date_range Date range for analysis
929 - * @return array Enhanced opportunities with intelligence
930 - */
931 - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
932 - // Get base opportunities data
933 - $opportunities_data = $this->get_seo_opportunities($date_range);
934 -
935 - // Check if there's an error in the data
936 - if (isset($opportunities_data['error'])) {
937 - return [
938 - 'success' => false,
939 - 'data' => null,
940 - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
941 - 'timestamp' => current_time('mysql')
942 - ];
943 - }
944 -
945 - // Check if we have real search console data for opportunities
946 - $search_performance = $opportunities_data['search_performance'] ?? [];
947 - $has_search_data = !empty($search_performance['rows']) ||
948 - ($search_performance['total_clicks'] ?? 0) > 0 ||
949 - ($search_performance['total_impressions'] ?? 0) > 0;
950 -
951 - if (!$has_search_data) {
952 - return [
953 - 'success' => false,
954 - 'data' => null,
955 - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
956 - 'timestamp' => current_time('mysql')
957 - ];
958 - }
959 -
960 - // Initialize intelligence classes — only available in Pro
961 - if (
962 - !class_exists('ThinkRank\SEO\SEO_Opportunity_Detector') ||
963 - !class_exists('ThinkRank\SEO\SEO_Scoring_Engine')
964 - ) {
965 - return [
966 - 'success' => false,
967 - 'data' => null,
968 - 'message' => 'Intelligent opportunities require ThinkRank Pro.',
969 - 'timestamp' => current_time('mysql')
970 - ];
971 - }
972 -
973 - $opportunity_detector = new SEO_Opportunity_Detector();
974 - $scoring_engine = new SEO_Scoring_Engine();
975 -
976 - $data = $opportunities_data;
977 -
978 - // Detect intelligent opportunities
979 - $search_console_data = $data['search_performance'] ?? [];
980 - $analytics_data = $data;
981 -
982 - $intelligent_opportunities = [
983 - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
984 - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
985 - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
986 - ];
987 -
988 - // Prioritize all opportunities
989 - $all_opportunities = array_merge(
990 - $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
991 - $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
992 - $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? []
993 - );
994 -
995 - $prioritized = $opportunity_detector->prioritize_opportunities($all_opportunities);
996 - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
997 -
998 - // Enhance original data with intelligence
999 - $enhanced_data = array_merge($data, [
1000 - 'intelligent_opportunities' => $intelligent_opportunities,
1001 - 'prioritized_opportunities' => $prioritized,
1002 - 'impact_effort_matrix' => $impact_matrix,
1003 - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
1004 - 'last_analyzed' => current_time('mysql')
1005 - ]);
1006 -
1007 - return [
1008 - 'success' => true,
1009 - 'data' => $enhanced_data,
1010 - 'message' => 'Intelligent SEO opportunities retrieved successfully'
1011 - ];
1012 - }
1013 -
1014 - /**
1015 - * Get SEO performance insights
1016 - *
1017 - * @param string $date_range Date range for analysis
1018 - * @return array SEO insights data
1019 - */
1020 - public function get_seo_insights(string $date_range = '30d'): array {
1021 - $cache_key = "seo_insights_{$date_range}";
1022 - $cached_data = get_transient($cache_key);
1023 -
1024 - if ($cached_data !== false) {
1025 - return [
1026 - 'success' => true,
1027 - 'data' => $cached_data,
1028 - 'cached' => true,
1029 - 'message' => 'SEO insights retrieved from cache'
1030 - ];
1031 - }
1032 -
1033 - try {
1034 - // Get dashboard data for analysis
1035 - $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
1036 -
1037 - if (!$dashboard_result['success']) {
1038 - return $dashboard_result;
1039 - }
1040 -
1041 - $dashboard_data = $dashboard_result['data'];
1042 - $intelligence = $dashboard_data['intelligence'] ?? [];
1043 -
1044 - // Initialize insight generator — only available in Pro
1045 - if (!class_exists('ThinkRank\SEO\SEO_Insight_Generator')) {
1046 - return [
1047 - 'success' => false,
1048 - 'data' => null,
1049 - 'message' => 'SEO insights require ThinkRank Pro.',
1050 - 'timestamp' => current_time('mysql')
1051 - ];
1052 - }
1053 -
1054 - $insight_generator = new SEO_Insight_Generator();
1055 -
1056 - // Collect all insights
1057 - $all_insights = [];
1058 -
1059 - if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
1060 - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
1061 - }
1062 -
1063 - if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
1064 - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
1065 - }
1066 -
1067 - if (!empty($intelligence['insights']['content_insights']['insights'])) {
1068 - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
1069 - }
1070 -
1071 - // Format and prioritize insights
1072 - $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
1073 - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
1074 -
1075 - $insights_data = [
1076 - 'insights' => $prioritized_insights['prioritized_insights'],
1077 - 'summary' => [
1078 - 'total_insights' => count($formatted_insights),
1079 - 'high_impact_count' => $prioritized_insights['high_impact_count'],
1080 - 'action_required_count' => $prioritized_insights['action_required_count']
1081 - ],
1082 - 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
1083 - 'generated_at' => current_time('mysql')
1084 - ];
1085 -
1086 - // Cache the results
1087 - set_transient($cache_key, $insights_data, $this->cache_duration);
1088 -
1089 - return [
1090 - 'success' => true,
1091 - 'data' => $insights_data,
1092 - 'cached' => false,
1093 - 'message' => 'SEO insights generated successfully'
1094 - ];
1095 - } catch (\Exception $e) {
1096 - return [
1097 - 'success' => false,
1098 - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
1099 - 'data' => null
1100 - ];
1101 - }
1102 - }
1103 -
1104 - /**
1105 - * Check if real analytics data is available
1106 - *
1107 - * @param array $dashboard_data Dashboard data to check
1108 - * @return bool True if real data is available
1109 - */
1110 - private function has_real_data(array $dashboard_data): bool {
1111 - // Check if we have meaningful traffic data
1112 - $traffic = $dashboard_data['traffic'] ?? [];
1113 - $search_performance = $dashboard_data['search_performance'] ?? [];
1114 -
1115 - $has_traffic = !empty($traffic) && (
1116 - ($traffic['sessions'] ?? 0) > 0 ||
1117 - ($traffic['pageviews'] ?? 0) > 0 ||
1118 - ($traffic['active_users'] ?? 0) > 0
1119 - );
1120 -
1121 - $has_search_data = !empty($search_performance) && (
1122 - !empty($search_performance['rows']) ||
1123 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1124 - ($search_performance['total_impressions'] ?? 0) > 0
1125 - );
1126 -
1127 - return $has_traffic || $has_search_data;
1128 - }
1129 -
1130 - /**
1131 - * Get historical data for trend comparison
1132 - *
1133 - * @param string $current_range Current date range
1134 - * @return array Historical data
1135 - */
1136 - private function get_historical_data(string $current_range): array {
1137 - // Calculate previous period based on current range
1138 - $previous_range = $this->calculate_previous_period($current_range);
1139 -
1140 - // Try to get actual historical data from previous period
1141 - $historical_data = $this->get_dashboard_data($previous_range);
1142 -
1143 - // Return the actual historical data (may be empty if no real data available)
1144 - return [
1145 - 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
1146 - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
1147 - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
1148 - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
1149 - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
1150 - ];
1151 - }
1152 -
1153 - /**
1154 - * Calculate previous period for comparison
1155 - *
1156 - * @param string $current_range Current range
1157 - * @return string Previous period range
1158 - */
1159 - private function calculate_previous_period(string $current_range): string {
1160 - // Simple mapping for now - could be enhanced with actual date calculations
1161 - $period_mapping = [
1162 - '7d' => '14d',
1163 - '30d' => '60d',
1164 - '90d' => '180d'
1165 - ];
1166 -
1167 - return $period_mapping[$current_range] ?? '60d';
1168 - }
1169 -
1170 - /**
1171 - * Generate opportunity summary
1172 - *
1173 - * @param array $opportunities All opportunities
1174 - * @return array Opportunity summary
1175 - */
1176 - private function generate_opportunity_summary(array $opportunities): array {
1177 - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
1178 - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
1179 - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
1180 -
1181 - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
1182 -
1183 - $potential_clicks = 0;
1184 - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
1185 - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
1186 - }
1187 -
1188 - return [
1189 - 'total_opportunities' => $total_opportunities,
1190 - 'quick_wins_count' => $quick_wins_count,
1191 - 'content_opportunities_count' => $content_opportunities_count,
1192 - 'keyword_opportunities_count' => $keyword_opportunities_count,
1193 - 'potential_additional_clicks' => $potential_clicks,
1194 - 'priority_recommendation' => $quick_wins_count > 0 ?
1195 - 'Focus on quick wins first for immediate impact' :
1196 - 'Focus on content optimization for long-term growth'
1197 - ];
1198 - }
1199 -
1200 - /**
1201 - * Clear intelligence cache
1202 - *
1203 - * @return array Clear result
1204 - */
1205 - public function clear_intelligence_cache(): array {
1206 - $intelligence_cache_keys = [
1207 - 'seo_insights_7d',
1208 - 'seo_insights_30d',
1209 - 'seo_insights_90d',
1210 - 'intelligent_dashboard_7d',
1211 - 'intelligent_dashboard_30d',
1212 - 'intelligent_dashboard_90d',
1213 - 'intelligent_opportunities_7d',
1214 - 'intelligent_opportunities_30d',
1215 - 'intelligent_opportunities_90d'
1216 - ];
1217 -
1218 - $cleared = 0;
1219 - foreach ($intelligence_cache_keys as $key) {
1220 - if (delete_transient($key)) {
1221 - $cleared++;
1222 - }
1223 - }
1224 -
1225 - return [
1226 - 'success' => true,
1227 - 'message' => "Cleared {$cleared} intelligence cache entries",
1228 - 'cleared_count' => $cleared,
1229 - 'timestamp' => current_time('mysql')
1230 - ];
1231 1059 }
1232 1060 }