PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.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 All 57 releases
← All changes | includes/seo/class-analytics-manager.php +418 -534 1.28.0 → 2.14.2 View file →
@@ -16,9 +16,8 @@
16 16
17 17 namespace ThinkRank\SEO;
18 18
19 19 use ThinkRank\Core\Settings_Manager;
20 -use ThinkRank\Core\Plan_Config;
21 20 use ThinkRank\Integrations\Google_Analytics_Client;
22 21 use ThinkRank\Integrations\Google_Search_Console_Client;
23 22 use ThinkRank\Integrations\Google_PageSpeed_Client;
24 23 use ThinkRank\Integrations\Google_Search_Analytics_Client;
@@ -88,8 +87,82 @@
88 87 */
89 88 private static bool $token_refreshed_this_request = false;
90 89
91 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 + /**
128 + * Transient prefix for the cached dashboard payload, suffixed with the
129 + * date range ("30d"). Carries the plugin prefix so uninstall's
130 + * `_transient_thinkrank_%` sweep removes it; the payload holds up to
131 + * 1,000 of the site's search queries (#918).
132 + *
133 + * @since 2.14.2
134 + * @var string
135 + */
136 + public const DASHBOARD_CACHE_PREFIX = 'thinkrank_analytics_dashboard_v5_';
137 +
138 + /**
139 + * Transient prefix for the cached SEO opportunities payload, suffixed
140 + * with the date range. Prefixed for the same reason as the dashboard's.
141 + *
142 + * @since 2.14.2
143 + * @var string
144 + */
145 + public const OPPORTUNITIES_CACHE_PREFIX = 'thinkrank_seo_opportunities_';
146 +
147 + /**
148 + * Every date range the dashboard cache is written under: the three the
149 + * UI offers plus the doubled previous-period ranges used for trends.
150 + *
151 + * @since 2.14.2
152 + * @var string[]
153 + */
154 + public const CACHED_DASHBOARD_RANGES = ['7d', '30d', '90d', '14d', '60d', '180d'];
155 +
156 + /**
157 + * Every date range the opportunities cache is written under.
158 + *
159 + * @since 2.14.2
160 + * @var string[]
161 + */
162 + public const CACHED_OPPORTUNITY_RANGES = ['7d', '30d', '90d'];
163 +
164 + /**
92 165 * Constructor
93 166 *
94 167 * @param Settings_Manager|null $settings_manager Settings manager instance
95 168 */
@@ -108,10 +181,11 @@
108 181 public function init(): void {
109 182 // Register custom cron interval (45 minutes)
110 183 add_filter('cron_schedules', [$this, 'add_cron_intervals']);
111 184
112 - // Initialize Google API clients
113 - add_action('init', [$this, 'initialize_clients']);
185 + // Initialize Google API clients — but only in the contexts that can use
186 + // them. See maybe_initialize_clients().
187 + add_action('init', [$this, 'maybe_initialize_clients']);
114 188
115 189 // Initialize token refresh scheduling
116 190 add_action('init', [$this, 'init_token_refresh']);
117 191
@@ -131,11 +205,16 @@
131 205 * @param array $schedules Existing cron schedules
132 206 * @return array Modified cron schedules
133 207 */
134 208 public function add_cron_intervals(array $schedules): array {
209 + // Only translate once `init` has run: wp_get_schedules() can be reached
210 + // before then (wp_schedule_event() at plugin boot does), and translating
211 + // that early trips the _load_textdomain_just_in_time notice on WP 6.7+.
135 212 $schedules['thinkrank_45min'] = [
136 213 'interval' => 2700, // 45 minutes in seconds
137 - 'display' => __('Every 45 Minutes', 'thinkrank')
214 + 'display' => did_action('init')
215 + ? __('Every 45 Minutes', 'thinkrank')
216 + : 'Every 45 Minutes'
138 217 ];
139 218 return $schedules;
140 219 }
141 220
@@ -172,8 +251,43 @@
172 251 return $this->get_setting('search_console_property', get_site_url());
173 252 }
174 253
175 254 /**
255 + * Initialize the Google clients on `init`, in the contexts that use them.
256 + *
257 + * initialize_clients() refreshes the OAuth token, which is a blocking
258 + * outbound POST to the OAuth proxy. Hooked unconditionally it ran on every
259 + * anonymous front-end request, so a proxy outage became a site-wide TTFB
260 + * collapse — with each visitor waiting for the network call, and none of
261 + * them able to use a Google client anyway. No front-end code path reads
262 + * one: every consumer is a REST endpoint, a cron callback or WP-CLI, and
263 + * each either calls initialize_clients() itself or goes through
264 + * get_search_console_client(), which initializes lazily (#383).
265 + *
266 + * @since 2.0.1
267 + * @return void
268 + */
269 + public function maybe_initialize_clients(): void {
270 + $wanted = is_admin()
271 + || wp_doing_cron()
272 + || (defined('REST_REQUEST') && REST_REQUEST)
273 + || (defined('WP_CLI') && WP_CLI);
274 +
275 + /**
276 + * Filter whether the Google API clients are initialized for this request.
277 + *
278 + * @since 2.0.1
279 + *
280 + * @param bool $wanted Whether to initialize the clients.
281 + */
282 + if (!apply_filters('thinkrank_initialize_google_clients', $wanted)) {
283 + return;
284 + }
285 +
286 + $this->initialize_clients();
287 + }
288 +
289 + /**
176 290 * Initialize Google API clients
177 291 * Following AI_Manager client initialization pattern
178 292 *
179 293 * @return void
@@ -370,10 +484,30 @@
370 484 // Calculate absolute expiration time (created + relative seconds)
371 485 $expiration_time = $created + $expires_in;
372 486
373 487 // Refresh if forced, expired, or expiring within 5 minutes (300 seconds)
374 - if ($force || $current_time >= ($expiration_time - 300)) {
488 + if (!$force && $current_time < ($expiration_time - 300)) {
489 + return;
490 + }
375 491
492 + // A failed exchange leaves google_token_created untouched, so the
493 + // expiry condition above stays true and the next request tries again.
494 + // Without a backoff a proxy outage means one blocking network call per
495 + // request, forever. A forced refresh — the user reconnecting — is a
496 + // deliberate act and skips the wait (#383).
497 + if (!$force && get_transient(self::REFRESH_BACKOFF)) {
498 + return;
499 + }
500 +
501 + // One exchange at a time. Concurrent callers past the expiry threshold
502 + // would otherwise all refresh at once and invalidate each other's
503 + // in-flight grants; the losers fall through with the current token and
504 + // pick up the new one on their next read.
505 + if (!$force && !$this->acquire_refresh_lock()) {
506 + return;
507 + }
508 +
509 + try {
376 510 // The proxy owns the Google app credentials; we only ever hand it
377 511 // the refresh token and let it perform the exchange.
378 512 $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [
379 513 'headers' => [
@@ -384,12 +518,13 @@
384 518 'action' => 'refresh',
385 519 'refresh_token' => $refresh_token,
386 520 'site' => home_url(),
387 521 ]),
388 - 'timeout' => 30
522 + 'timeout' => self::REFRESH_TIMEOUT
389 523 ]);
390 524
391 525 if (is_wp_error($response)) {
526 + $this->back_off_refresh();
392 527 return;
393 528 }
394 529
395 530 $body = wp_remote_retrieve_body($response);
@@ -402,12 +537,15 @@
402 537 // the site is connected — otherwise the UI shows "Connected"
403 538 // while every API call 401s.
404 539 if (($data['error'] ?? '') === 'invalid_grant') {
405 540 Google_OAuth_Proxy::mark_revoked();
541 + return;
406 542 }
407 543
408 544 // Any other failure (network blip, proxy 502) is transient;
409 - // leave the credentials alone and let the next run retry.
545 + // leave the credentials alone and let the next run retry —
546 + // after the backoff, not on the very next request.
547 + $this->back_off_refresh();
410 548 return;
411 549 }
412 550
413 551 // Update settings with new token data
@@ -423,15 +561,79 @@
423 561 'google_refresh_token' => $data['refresh_token']
424 562 ], 'integrations');
425 563 }
426 564
565 + // A success clears any backoff a previous failure left behind.
566 + delete_transient(self::REFRESH_BACKOFF);
567 +
427 568 // Drop the memoized settings merge so subsequent reads (e.g.
428 569 // re-initializing clients) see the fresh token.
429 570 $this->merged_settings = null;
571 + } finally {
572 + $this->release_refresh_lock();
430 573 }
431 574 }
432 575
433 576 /**
577 + * Take the single-flight lock for the refresh exchange.
578 + *
579 + * @since 2.0.1
580 + * @return bool True when this request holds the lock.
581 + */
582 + private function acquire_refresh_lock(): bool {
583 + // With a persistent object cache, add is atomic — memcached and Redis
584 + // both fail an ADD on an existing key — so exactly one caller wins.
585 + if (wp_using_ext_object_cache()) {
586 + return (bool) wp_cache_add(self::REFRESH_LOCK, time(), 'thinkrank', self::REFRESH_LOCK_TTL);
587 + }
588 +
589 + // Without one, the options table is the shared store, and the unique
590 + // index on option_name gives add_option() the same all-or-nothing
591 + // result. set_transient() would not: it is an update, so every
592 + // concurrent caller would "win".
593 + if (add_option(self::REFRESH_LOCK, time(), '', 'no')) {
594 + return true;
595 + }
596 +
597 + // Reclaim a lock whose holder died before releasing it.
598 + $held = (int) get_option(self::REFRESH_LOCK);
599 +
600 + if ($held > 0 && (time() - $held) > self::REFRESH_LOCK_TTL) {
601 + delete_option(self::REFRESH_LOCK);
602 +
603 + return (bool) add_option(self::REFRESH_LOCK, time(), '', 'no');
604 + }
605 +
606 + return false;
607 + }
608 +
609 + /**
610 + * Release the single-flight lock.
611 + *
612 + * @since 2.0.1
613 + * @return void
614 + */
615 + private function release_refresh_lock(): void {
616 + if (wp_using_ext_object_cache()) {
617 + wp_cache_delete(self::REFRESH_LOCK, 'thinkrank');
618 +
619 + return;
620 + }
621 +
622 + delete_option(self::REFRESH_LOCK);
623 + }
624 +
625 + /**
626 + * Stop retrying the exchange for a while after a failure.
627 + *
628 + * @since 2.0.1
629 + * @return void
630 + */
631 + private function back_off_refresh(): void {
632 + set_transient(self::REFRESH_BACKOFF, time(), self::REFRESH_BACKOFF_TTL);
633 + }
634 +
635 + /**
434 636 * Test all Google API connections
435 637 * Following ThinkRank test_connection patterns
436 638 *
437 639 * @return array Connection test results
@@ -436,66 +638,72 @@
436 638 *
437 639 * @return array Connection test results
438 640 */
439 641 public function test_connections(): array {
642 + // Every entry carries a `message`, configured or not: the result is
643 + // handed to the Integrations screen as JSON, and a key that exists on
644 + // some services and not others reads as `undefined` there.
440 645 $results = [
441 - 'google_analytics' => ['status' => 'not_configured'],
442 - 'search_console' => ['status' => 'not_configured'],
443 - 'pagespeed' => ['status' => 'not_configured']
646 + 'google_analytics' => ['status' => 'not_configured', 'message' => ''],
647 + 'search_console' => ['status' => 'not_configured', 'message' => ''],
648 + 'pagespeed' => ['status' => 'not_configured', 'message' => '']
444 649 ];
445 650
446 - // Test Google Analytics connection
447 - if ($this->analytics_client) {
448 - try {
449 - $test_result = $this->analytics_client->test_connection();
450 - $results['google_analytics'] = [
451 - 'status' => $test_result['success'] ? 'connected' : 'error',
452 - 'message' => $test_result['message'],
453 - 'details' => $test_result
454 - ];
455 - } catch (\Exception $e) {
456 - $results['google_analytics'] = [
457 - 'status' => 'error',
458 - 'message' => $e->getMessage()
459 - ];
651 + // One shape for all three. They were three copies of the same block
652 + // differing only in the client, which is how the same unguarded read
653 + // came to exist in triplicate (#852).
654 + $clients = [
655 + 'google_analytics' => $this->analytics_client,
656 + 'search_console' => $this->search_console_client,
657 + 'pagespeed' => $this->pagespeed_client,
658 + ];
659 +
660 + foreach ($clients as $service => $client) {
661 + if (!$client) {
662 + continue;
460 663 }
664 +
665 + $results[$service] = $this->describe_connection_test($client);
461 666 }
462 667
463 - // Test Search Console connection
464 - if ($this->search_console_client) {
465 - try {
466 - $test_result = $this->search_console_client->test_connection();
467 - $results['search_console'] = [
468 - 'status' => $test_result['success'] ? 'connected' : 'error',
469 - 'message' => $test_result['message'],
470 - 'details' => $test_result
471 - ];
472 - } catch (\Exception $e) {
473 - $results['search_console'] = [
474 - 'status' => 'error',
475 - 'message' => $e->getMessage()
476 - ];
668 + return $results;
669 + }
670 +
671 + /**
672 + * Run one client's connection test and report it in a fixed shape.
673 + *
674 + * The clients answer success with `message` and failure with `error`, and
675 + * this read `$test_result['message']` unconditionally — so a failed test
676 + * raised "Undefined array key" and handed the admin an error with
677 + * `message => null`. The reason the client had in hand was the one thing
678 + * the screen needed. The clients now set `message` on both branches; the
679 + * fallbacks here cover a client that does not, including one that answers
680 + * with neither key.
681 + *
682 + * @since 2.12.0
683 + *
684 + * @param object $client A client exposing test_connection(): array.
685 + * @return array{status:string, message:string, details?:array}
686 + */
687 + private function describe_connection_test($client): array {
688 + try {
689 + $test_result = $client->test_connection();
690 +
691 + if (!is_array($test_result)) {
692 + return ['status' => 'error', 'message' => ''];
477 693 }
478 - }
479 694
480 - // Test PageSpeed connection
481 - if ($this->pagespeed_client) {
482 - try {
483 - $test_result = $this->pagespeed_client->test_connection();
484 - $results['pagespeed'] = [
485 - 'status' => $test_result['success'] ? 'connected' : 'error',
486 - 'message' => $test_result['message'],
487 - 'details' => $test_result
488 - ];
489 - } catch (\Exception $e) {
490 - $results['pagespeed'] = [
491 - 'status' => 'error',
492 - 'message' => $e->getMessage()
493 - ];
494 - }
695 + return [
696 + 'status' => !empty($test_result['success']) ? 'connected' : 'error',
697 + 'message' => (string) ($test_result['message'] ?? $test_result['error'] ?? ''),
698 + 'details' => $test_result,
699 + ];
700 + } catch (\Exception $e) {
701 + return [
702 + 'status' => 'error',
703 + 'message' => $e->getMessage(),
704 + ];
495 705 }
496 -
497 - return $results;
498 706 }
499 707
500 708 /**
501 709 * Get analytics dashboard data
@@ -502,11 +710,13 @@
502 710 * Combines data from all Google APIs with caching
503 711 *
504 712 * @param string $date_range Date range for data
505 713 * @return array Dashboard data
714 + *
715 + * @throws \Exception On failure.
506 716 */
507 717 public function get_dashboard_data(string $date_range = '30d'): array {
508 - $cache_key = "analytics_dashboard_v5_{$date_range}";
718 + $cache_key = self::DASHBOARD_CACHE_PREFIX . $date_range;
509 719 $cached_data = get_transient($cache_key);
510 720
511 721 if ($cached_data !== false) {
512 722 // Core Web Vitals are cached separately with a much shorter
@@ -543,10 +753,8 @@
543 753 // 401s are re-thrown so the token-refresh retry below runs.
544 754 if ($this->analytics_client) {
545 755 try {
546 756 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
547 - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
548 - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
549 757 } catch (\Exception $ga_error) {
550 758 if ($ga_error->getCode() === 401) {
551 759 throw $ga_error;
552 760 }
@@ -579,35 +787,21 @@
579 787 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
580 788 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
581 789 }
582 790
583 - // Calculate position distribution
584 - $position_distribution = [
585 - 'top_3' => 0,
586 - '4_10' => 0,
587 - '10_50' => 0,
588 - '51_100' => 0
589 - ];
791 + // Position distribution over every query with an
792 + // impression, not over the 1,000-row list above (#913).
793 + $position_distribution = $this->search_analytics_client
794 + ? $this->count_position_distribution($site_url, $start_date, $end_date, $search_performance['rows'] ?? [])
795 + : self::bucket_positions(
796 + $search_performance['rows'] ?? [],
797 + count($search_performance['rows'] ?? []) < 1000
798 + );
590 799
591 - foreach ($search_performance['rows'] ?? [] as $row) {
592 - $position = $row['position'] ?? 0;
593 - if ($position <= 3) {
594 - $position_distribution['top_3']++;
595 - } elseif ($position <= 10) {
596 - $position_distribution['4_10']++;
597 - } elseif ($position <= 50) {
598 - $position_distribution['10_50']++;
599 - } elseif ($position <= 100) {
600 - $position_distribution['51_100']++;
601 - }
602 - }
603 -
604 800 $dashboard_data['search_performance'] = array_merge($search_performance, [
605 801 'totals' => $totals,
606 802 'position_distribution' => $position_distribution
607 803 ]);
608 -
609 - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
610 804 } // Closing Search Console block
611 805
612 806 // If successful, break loop
613 807 break;
@@ -645,8 +839,132 @@
645 839 return $dashboard_data;
646 840 }
647 841
648 842 /**
843 + * Rows per page when counting the position distribution. The most the
844 + * Search Analytics API returns in one request.
845 + *
846 + * @since 2.15.0
847 + */
848 + private const POSITION_PAGE_SIZE = 25000;
849 +
850 + /**
851 + * Pages read before the count stops: 200,000 queries. A property past
852 + * that is counted over its top 200,000 by clicks and flagged incomplete.
853 + *
854 + * @since 2.15.0
855 + */
856 + private const POSITION_MAX_PAGES = 8;
857 +
858 + /**
859 + * Count the period's queries into position buckets across the whole
860 + * property (#913).
861 + *
862 + * The dashboard's query list is capped at 1,000 rows ordered by clicks,
863 + * so counting it told any larger site it had exactly 1,000 queries and
864 + * dropped the long tail, which is where positions 51-100 live. When that
865 + * list came back short it already holds every query and is counted as
866 + * is, with no extra request. Otherwise the property is paged with
867 + * `startRow` at POSITION_PAGE_SIZE rows until a short page, counting as
868 + * rows arrive rather than keeping them.
869 + *
870 + * A page that fails (other than a 401, which is re-thrown so the token
871 + * refresh runs) leaves the count at what was read so far, flagged
872 + * `complete: false`, rather than failing the whole dashboard.
873 + *
874 + * @since 2.15.0
875 + *
876 + * @param string $site_url Search Console property.
877 + * @param string $start_date Window start (Y-m-d).
878 + * @param string $end_date Window end (Y-m-d).
879 + * @param array $first_rows The capped query list already fetched.
880 + * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
881 + * @throws \Exception On a 401, so get_dashboard_data() can refresh the token.
882 + */
883 + private function count_position_distribution(string $site_url, string $start_date, string $end_date, array $first_rows): array {
884 + if (count($first_rows) < 1000) {
885 + return self::bucket_positions($first_rows, true);
886 + }
887 +
888 + $distribution = self::bucket_positions([], true);
889 + $start_row = 0;
890 +
891 + for ($page = 0; $page < self::POSITION_MAX_PAGES; $page++) {
892 + try {
893 + $rows = $this->search_analytics_client->get_search_analytics_data(
894 + $site_url,
895 + $start_date,
896 + $end_date,
897 + ['query'],
898 + self::POSITION_PAGE_SIZE,
899 + $start_row
900 + )['rows'] ?? [];
901 + } catch (\Exception $e) {
902 + if ($e->getCode() === 401) {
903 + throw $e;
904 + }
905 + // Nothing read yet: the capped list is the best there is.
906 + $partial = $start_row === 0 ? self::bucket_positions($first_rows, false) : $distribution;
907 + $partial['complete'] = false;
908 + return $partial;
909 + }
910 +
911 + $page_counts = self::bucket_positions($rows, true);
912 + foreach (['top_3', '4_10', '10_50', '51_100', 'over_100'] as $bucket) {
913 + $distribution[$bucket] += $page_counts[$bucket];
914 + }
915 +
916 + if (count($rows) < self::POSITION_PAGE_SIZE) {
917 + return $distribution;
918 + }
919 + $start_row += self::POSITION_PAGE_SIZE;
920 + }
921 +
922 + $distribution['complete'] = false;
923 + return $distribution;
924 + }
925 +
926 + /**
927 + * Bucket Search Console rows by average position.
928 + *
929 + * `10_50` is the historical key for positions 11-50. Rows past 100 are
930 + * counted in `over_100`: they still had impressions.
931 + *
932 + * @since 2.15.0
933 + *
934 + * @param array $rows Search Console rows.
935 + * @param bool $complete Whether $rows is every query in the window.
936 + * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
937 + */
938 + private static function bucket_positions(array $rows, bool $complete): array {
939 + $distribution = [
940 + 'top_3' => 0,
941 + '4_10' => 0,
942 + '10_50' => 0,
943 + '51_100' => 0,
944 + 'over_100' => 0,
945 + 'complete' => $complete,
946 + ];
947 +
948 + foreach ($rows as $row) {
949 + $position = (float) ($row['position'] ?? 0);
950 + if ($position <= 3) {
951 + $distribution['top_3']++;
952 + } elseif ($position <= 10) {
953 + $distribution['4_10']++;
954 + } elseif ($position <= 50) {
955 + $distribution['10_50']++;
956 + } elseif ($position <= 100) {
957 + $distribution['51_100']++;
958 + } else {
959 + $distribution['over_100']++;
960 + }
961 + }
962 +
963 + return $distribution;
964 + }
965 +
966 + /**
649 967 * Get Core Web Vitals for the analytics dashboard, cached independently
650 968 * of the dashboard payload.
651 969 *
652 970 * Successful results are cached for 1 hour; failures are never cached
@@ -689,9 +1007,9 @@
689 1007 * @param string $date_range Date range for analysis
690 1008 * @return array SEO opportunities
691 1009 */
692 1010 public function get_seo_opportunities(string $date_range = '30d'): array {
693 - $cache_key = "seo_opportunities_{$date_range}";
1011 + $cache_key = self::OPPORTUNITIES_CACHE_PREFIX . $date_range;
694 1012 $cached_data = get_transient($cache_key);
695 1013
696 1014 if ($cached_data !== false) {
697 1015 return $cached_data;
@@ -757,9 +1075,9 @@
757 1075 * @var array|null
758 1076 */
759 1077 private ?array $merged_settings = null;
760 1078
761 - private function get_setting(string $key, $default = '') {
1079 + private function get_setting(string $key, $fallback = '') {
762 1080 if ($this->merged_settings === null) {
763 1081 // Merge settings to allow access to both categories. Memoized:
764 1082 // this getter is called many times per request and each category
765 1083 // read decrypts every sensitive option again.
@@ -768,9 +1086,9 @@
768 1086 $this->settings_manager->get_settings('seo_analytics')
769 1087 );
770 1088 }
771 1089
772 - return $this->merged_settings[$key] ?? $default;
1090 + return $this->merged_settings[$key] ?? $fallback;
773 1091 }
774 1092
775 1093 /**
776 1094 * One-click setup for Google Search Console verification
@@ -805,41 +1123,24 @@
805 1123 }
806 1124 }
807 1125
808 1126 /**
809 - * Get site indexing status
1127 + * Every transient the dashboard and opportunities caches are written
1128 + * under. One list, so "Refresh data", the settings save and any later
1129 + * caller clear the same keys the readers use.
810 1130 *
811 - * @return array Indexing status data
1131 + * @since 2.14.2
1132 + * @return string[]
812 1133 */
813 - public function get_indexing_status(): array {
814 - $cache_key = 'indexing_status';
815 - $cached_data = get_transient($cache_key);
816 -
817 - if ($cached_data !== false) {
818 - return $cached_data;
1134 + public static function dashboard_cache_keys(): array {
1135 + $keys = [];
1136 + foreach (self::CACHED_DASHBOARD_RANGES as $range) {
1137 + $keys[] = self::DASHBOARD_CACHE_PREFIX . $range;
819 1138 }
820 -
821 - $indexing_data = [
822 - 'status' => 'unknown',
823 - 'last_updated' => current_time('mysql')
824 - ];
825 -
826 - try {
827 - if ($this->search_console_client) {
828 - $site_url = $this->get_setting('search_console_property', get_site_url());
829 - $indexing_data = $this->search_console_client->get_indexing_status($site_url);
830 - }
831 - } catch (\Exception $e) {
832 - $indexing_data['error'] = $e->getMessage();
1139 + foreach (self::CACHED_OPPORTUNITY_RANGES as $range) {
1140 + $keys[] = self::OPPORTUNITIES_CACHE_PREFIX . $range;
833 1141 }
834 -
835 - // Cache successful results for 1 hour. Errors are never cached, so a
836 - // transient Google failure isn't served as "no data" for a full hour.
837 - if (empty($indexing_data['error'])) {
838 - set_transient($cache_key, $indexing_data, 3600);
839 - }
840 -
841 - return $indexing_data;
1142 + return $keys;
842 1143 }
843 1144
844 1145 /**
845 1146 * Force refresh of all cached data
@@ -849,24 +1150,12 @@
849 1150 public function refresh_data(): array {
850 1151 // Clear all analytics-related transients, including the previous-period
851 1152 // ranges used for trend comparison (14d/60d/180d) and the separately
852 1153 // cached Core Web Vitals payload.
853 - $cache_keys = [
854 - 'analytics_dashboard_v5_7d',
855 - 'analytics_dashboard_v5_30d',
856 - 'analytics_dashboard_v5_90d',
857 - 'analytics_dashboard_v5_14d',
858 - 'analytics_dashboard_v5_60d',
859 - 'analytics_dashboard_v5_180d',
860 - 'seo_opportunities_7d',
861 - 'seo_opportunities_30d',
862 - 'seo_opportunities_90d',
863 - 'seo_insights_7d',
864 - 'seo_insights_30d',
865 - 'seo_insights_90d',
866 - 'indexing_status',
867 - 'thinkrank_dashboard_cwv'
868 - ];
1154 + $cache_keys = array_merge(
1155 + self::dashboard_cache_keys(),
1156 + ['indexing_status', 'thinkrank_dashboard_cwv']
1157 + );
869 1158
870 1159 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
871 1160 // URL + device, so compute them for the URL/device combinations the
872 1161 // plugin actually tests.
@@ -931,411 +1220,6 @@
931 1220 */
932 1221 public function cleanup_cache(): void {
933 1222 // WordPress handles transient cleanup automatically
934 1223 // This method is for future custom cache cleanup if needed
935 - }
936 -
937 - // ========================================
938 - // SEO Intelligence Enhancement Methods
939 - // ========================================
940 -
941 - /**
942 - * Get intelligent dashboard data with trends and insights
943 - *
944 - * @param string $date_range Date range for analysis
945 - * @return array Enhanced dashboard data with intelligence
946 - */
947 - public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
948 - // Get base dashboard data
949 - $dashboard_data = $this->get_dashboard_data($date_range);
950 -
951 - // Check if there's an error in the data
952 - if (isset($dashboard_data['error'])) {
953 - return [
954 - 'success' => false,
955 - 'data' => null,
956 - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
957 - 'timestamp' => current_time('mysql')
958 - ];
959 - }
960 -
961 - // Check if we have real data available
962 - if (!$this->has_real_data($dashboard_data)) {
963 - return [
964 - 'success' => false,
965 - 'data' => null,
966 - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
967 - 'timestamp' => current_time('mysql')
968 - ];
969 - }
970 -
971 - // Intelligence engine is a Pro-only feature. The four SEO_* classes ship
972 - // in Free too (the PSR-4 autoloader would resolve them), so class_exists()
973 - // can't gate this — check the real Pro signal instead.
974 - if (!Plan_Config::is_pro()) {
975 - return [
976 - 'success' => false,
977 - 'data' => null,
978 - 'message' => 'Intelligent dashboard requires ThinkRank Pro.',
979 - 'timestamp' => current_time('mysql')
980 - ];
981 - }
982 -
983 - $trend_analyzer = new SEO_Trend_Analyzer();
984 - $scoring_engine = new SEO_Scoring_Engine();
985 - $insight_generator = new SEO_Insight_Generator();
986 -
987 - $data = $dashboard_data;
988 -
989 - // Generate trend analysis
990 - $current_data = $data;
991 - $historical_data = $this->get_historical_data($date_range);
992 -
993 - $trends = [
994 - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
995 - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
996 - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
997 - ];
998 -
999 - // Calculate SEO health score
1000 - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
1001 -
1002 - // Generate insights
1003 - $insights = [
1004 - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
1005 - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
1006 - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
1007 - ];
1008 -
1009 - // Combine all intelligence data
1010 - $enhanced_data = array_merge($data, [
1011 - 'intelligence' => [
1012 - 'trends' => $trends,
1013 - 'seo_health_score' => $seo_health,
1014 - 'insights' => $insights,
1015 - 'last_analyzed' => current_time('mysql')
1016 - ]
1017 - ]);
1018 -
1019 - return [
1020 - 'success' => true,
1021 - 'data' => $enhanced_data,
1022 - 'message' => 'Intelligent dashboard data retrieved successfully'
1023 - ];
1024 - }
1025 -
1026 - /**
1027 - * Get intelligent SEO opportunities with prioritization
1028 - *
1029 - * @param string $date_range Date range for analysis
1030 - * @return array Enhanced opportunities with intelligence
1031 - */
1032 - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
1033 - // Get base opportunities data
1034 - $opportunities_data = $this->get_seo_opportunities($date_range);
1035 -
1036 - // Check if there's an error in the data
1037 - if (isset($opportunities_data['error'])) {
1038 - return [
1039 - 'success' => false,
1040 - 'data' => null,
1041 - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
1042 - 'timestamp' => current_time('mysql')
1043 - ];
1044 - }
1045 -
1046 - // The opportunities payload itself has no search_performance key — that
1047 - // data lives in the (cached) dashboard payload. Pull it from there both
1048 - // for the availability check and as input for the opportunity detectors;
1049 - // checking $opportunities_data['search_performance'] here used to make
1050 - // this method always bail with "No Search Console data available".
1051 - $dashboard_data = $this->get_dashboard_data($date_range);
1052 - $search_performance = $dashboard_data['search_performance'] ?? [];
1053 - $opportunities_data['search_performance'] = $search_performance;
1054 -
1055 - $has_search_data = !empty($search_performance['rows']) ||
1056 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1057 - ($search_performance['total_impressions'] ?? 0) > 0;
1058 -
1059 - if (!$has_search_data) {
1060 - return [
1061 - 'success' => false,
1062 - 'data' => null,
1063 - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
1064 - 'timestamp' => current_time('mysql')
1065 - ];
1066 - }
1067 -
1068 - // Intelligence engine is a Pro-only feature — gate on the real Pro signal,
1069 - // not class_exists() (the classes ship in Free and would autoload).
1070 - if (!Plan_Config::is_pro()) {
1071 - return [
1072 - 'success' => false,
1073 - 'data' => null,
1074 - 'message' => 'Intelligent opportunities require ThinkRank Pro.',
1075 - 'timestamp' => current_time('mysql')
1076 - ];
1077 - }
1078 -
1079 - $opportunity_detector = new SEO_Opportunity_Detector();
1080 - $scoring_engine = new SEO_Scoring_Engine();
1081 -
1082 - $data = $opportunities_data;
1083 -
1084 - // Detect intelligent opportunities
1085 - $search_console_data = $data['search_performance'] ?? [];
1086 - $analytics_data = $data;
1087 -
1088 - $intelligent_opportunities = [
1089 - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
1090 - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
1091 - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
1092 - ];
1093 -
1094 - // Prioritize all opportunities. prioritize_opportunities() expects
1095 - // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list.
1096 - $opportunities_by_category = [
1097 - 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
1098 - 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
1099 - 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [],
1100 - ];
1101 - $all_opportunities = array_merge(...array_values($opportunities_by_category));
1102 -
1103 - $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category);
1104 - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
1105 -
1106 - // Enhance original data with intelligence
1107 - $enhanced_data = array_merge($data, [
1108 - 'intelligent_opportunities' => $intelligent_opportunities,
1109 - 'prioritized_opportunities' => $prioritized,
1110 - 'impact_effort_matrix' => $impact_matrix,
1111 - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
1112 - 'last_analyzed' => current_time('mysql')
1113 - ]);
1114 -
1115 - return [
1116 - 'success' => true,
1117 - 'data' => $enhanced_data,
1118 - 'message' => 'Intelligent SEO opportunities retrieved successfully'
1119 - ];
1120 - }
1121 -
1122 - /**
1123 - * Get SEO performance insights
1124 - *
1125 - * @param string $date_range Date range for analysis
1126 - * @return array SEO insights data
1127 - */
1128 - public function get_seo_insights(string $date_range = '30d'): array {
1129 - $cache_key = "seo_insights_{$date_range}";
1130 - $cached_data = get_transient($cache_key);
1131 -
1132 - if ($cached_data !== false) {
1133 - return [
1134 - 'success' => true,
1135 - 'data' => $cached_data,
1136 - 'cached' => true,
1137 - 'message' => 'SEO insights retrieved from cache'
1138 - ];
1139 - }
1140 -
1141 - try {
1142 - // Get dashboard data for analysis
1143 - $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
1144 -
1145 - if (!$dashboard_result['success']) {
1146 - return $dashboard_result;
1147 - }
1148 -
1149 - $dashboard_data = $dashboard_result['data'];
1150 - $intelligence = $dashboard_data['intelligence'] ?? [];
1151 -
1152 - // Insights are a Pro-only feature — gate on the real Pro signal,
1153 - // not class_exists() (SEO_Insight_Generator ships in Free too).
1154 - if (!Plan_Config::is_pro()) {
1155 - return [
1156 - 'success' => false,
1157 - 'data' => null,
1158 - 'message' => 'SEO insights require ThinkRank Pro.',
1159 - 'timestamp' => current_time('mysql')
1160 - ];
1161 - }
1162 -
1163 - $insight_generator = new SEO_Insight_Generator();
1164 -
1165 - // Collect all insights
1166 - $all_insights = [];
1167 -
1168 - if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
1169 - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
1170 - }
1171 -
1172 - if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
1173 - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
1174 - }
1175 -
1176 - if (!empty($intelligence['insights']['content_insights']['insights'])) {
1177 - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
1178 - }
1179 -
1180 - // Format and prioritize insights
1181 - $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
1182 - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
1183 -
1184 - $insights_data = [
1185 - 'insights' => $prioritized_insights['prioritized_insights'],
1186 - 'summary' => [
1187 - 'total_insights' => count($formatted_insights),
1188 - 'high_impact_count' => $prioritized_insights['high_impact_count'],
1189 - 'action_required_count' => $prioritized_insights['action_required_count']
1190 - ],
1191 - 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
1192 - 'generated_at' => current_time('mysql')
1193 - ];
1194 -
1195 - // Cache the results
1196 - set_transient($cache_key, $insights_data, $this->cache_duration);
1197 -
1198 - return [
1199 - 'success' => true,
1200 - 'data' => $insights_data,
1201 - 'cached' => false,
1202 - 'message' => 'SEO insights generated successfully'
1203 - ];
1204 - } catch (\Exception $e) {
1205 - return [
1206 - 'success' => false,
1207 - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
1208 - 'data' => null
1209 - ];
1210 - }
1211 - }
1212 -
1213 - /**
1214 - * Check if real analytics data is available
1215 - *
1216 - * @param array $dashboard_data Dashboard data to check
1217 - * @return bool True if real data is available
1218 - */
1219 - private function has_real_data(array $dashboard_data): bool {
1220 - // Check if we have meaningful traffic data
1221 - $traffic = $dashboard_data['traffic'] ?? [];
1222 - $search_performance = $dashboard_data['search_performance'] ?? [];
1223 -
1224 - $has_traffic = !empty($traffic) && (
1225 - ($traffic['sessions'] ?? 0) > 0 ||
1226 - ($traffic['pageviews'] ?? 0) > 0 ||
1227 - ($traffic['active_users'] ?? 0) > 0
1228 - );
1229 -
1230 - $has_search_data = !empty($search_performance) && (
1231 - !empty($search_performance['rows']) ||
1232 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1233 - ($search_performance['total_impressions'] ?? 0) > 0
1234 - );
1235 -
1236 - return $has_traffic || $has_search_data;
1237 - }
1238 -
1239 - /**
1240 - * Get historical data for trend comparison
1241 - *
1242 - * @param string $current_range Current date range
1243 - * @return array Historical data
1244 - */
1245 - private function get_historical_data(string $current_range): array {
1246 - // Calculate previous period based on current range
1247 - $previous_range = $this->calculate_previous_period($current_range);
1248 -
1249 - // Try to get actual historical data from previous period
1250 - $historical_data = $this->get_dashboard_data($previous_range);
1251 -
1252 - // Return the actual historical data (may be empty if no real data available)
1253 - return [
1254 - 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
1255 - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
1256 - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
1257 - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
1258 - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
1259 - ];
1260 - }
1261 -
1262 - /**
1263 - * Calculate previous period for comparison
1264 - *
1265 - * @param string $current_range Current range
1266 - * @return string Previous period range
1267 - */
1268 - private function calculate_previous_period(string $current_range): string {
1269 - // Simple mapping for now - could be enhanced with actual date calculations
1270 - $period_mapping = [
1271 - '7d' => '14d',
1272 - '30d' => '60d',
1273 - '90d' => '180d'
1274 - ];
1275 -
1276 - return $period_mapping[$current_range] ?? '60d';
1277 - }
1278 -
1279 - /**
1280 - * Generate opportunity summary
1281 - *
1282 - * @param array $opportunities All opportunities
1283 - * @return array Opportunity summary
1284 - */
1285 - private function generate_opportunity_summary(array $opportunities): array {
1286 - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
1287 - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
1288 - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
1289 -
1290 - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
1291 -
1292 - $potential_clicks = 0;
1293 - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
1294 - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
1295 - }
1296 -
1297 - return [
1298 - 'total_opportunities' => $total_opportunities,
1299 - 'quick_wins_count' => $quick_wins_count,
1300 - 'content_opportunities_count' => $content_opportunities_count,
1301 - 'keyword_opportunities_count' => $keyword_opportunities_count,
1302 - 'potential_additional_clicks' => $potential_clicks,
1303 - 'priority_recommendation' => $quick_wins_count > 0 ?
1304 - 'Focus on quick wins first for immediate impact' :
1305 - 'Focus on content optimization for long-term growth'
1306 - ];
1307 - }
1308 -
1309 - /**
1310 - * Clear intelligence cache
1311 - *
1312 - * @return array Clear result
1313 - */
1314 - public function clear_intelligence_cache(): array {
1315 - $intelligence_cache_keys = [
1316 - 'seo_insights_7d',
1317 - 'seo_insights_30d',
1318 - 'seo_insights_90d',
1319 - 'intelligent_dashboard_7d',
1320 - 'intelligent_dashboard_30d',
1321 - 'intelligent_dashboard_90d',
1322 - 'intelligent_opportunities_7d',
1323 - 'intelligent_opportunities_30d',
1324 - 'intelligent_opportunities_90d'
1325 - ];
1326 -
1327 - $cleared = 0;
1328 - foreach ($intelligence_cache_keys as $key) {
1329 - if (delete_transient($key)) {
1330 - $cleared++;
1331 - }
1332 - }
1333 -
1334 - return [
1335 - 'success' => true,
1336 - 'message' => "Cleared {$cleared} intelligence cache entries",
1337 - 'cleared_count' => $cleared,
1338 - 'timestamp' => current_time('mysql')
1339 - ];
1340 1224 }
1341 1225 }