PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
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 1.27.0 All 56 releases
← All changes | includes/seo/class-analytics-manager.php +359 -524 1.29.0 → 2.14.1 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,45 @@
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 + /**
92 128 * Constructor
93 129 *
94 130 * @param Settings_Manager|null $settings_manager Settings manager instance
95 131 */
@@ -108,10 +144,11 @@
108 144 public function init(): void {
109 145 // Register custom cron interval (45 minutes)
110 146 add_filter('cron_schedules', [$this, 'add_cron_intervals']);
111 147
112 - // Initialize Google API clients
113 - 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']);
114 151
115 152 // Initialize token refresh scheduling
116 153 add_action('init', [$this, 'init_token_refresh']);
117 154
@@ -131,11 +168,16 @@
131 168 * @param array $schedules Existing cron schedules
132 169 * @return array Modified cron schedules
133 170 */
134 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+.
135 175 $schedules['thinkrank_45min'] = [
136 176 'interval' => 2700, // 45 minutes in seconds
137 - 'display' => __('Every 45 Minutes', 'thinkrank')
177 + 'display' => did_action('init')
178 + ? __('Every 45 Minutes', 'thinkrank')
179 + : 'Every 45 Minutes'
138 180 ];
139 181 return $schedules;
140 182 }
141 183
@@ -172,8 +214,43 @@
172 214 return $this->get_setting('search_console_property', get_site_url());
173 215 }
174 216
175 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 + /**
176 253 * Initialize Google API clients
177 254 * Following AI_Manager client initialization pattern
178 255 *
179 256 * @return void
@@ -370,10 +447,30 @@
370 447 // Calculate absolute expiration time (created + relative seconds)
371 448 $expiration_time = $created + $expires_in;
372 449
373 450 // Refresh if forced, expired, or expiring within 5 minutes (300 seconds)
374 - if ($force || $current_time >= ($expiration_time - 300)) {
451 + if (!$force && $current_time < ($expiration_time - 300)) {
452 + return;
453 + }
375 454
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 + }
463 +
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 {
376 473 // The proxy owns the Google app credentials; we only ever hand it
377 474 // the refresh token and let it perform the exchange.
378 475 $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [
379 476 'headers' => [
@@ -384,12 +481,13 @@
384 481 'action' => 'refresh',
385 482 'refresh_token' => $refresh_token,
386 483 'site' => home_url(),
387 484 ]),
388 - 'timeout' => 30
485 + 'timeout' => self::REFRESH_TIMEOUT
389 486 ]);
390 487
391 488 if (is_wp_error($response)) {
489 + $this->back_off_refresh();
392 490 return;
393 491 }
394 492
395 493 $body = wp_remote_retrieve_body($response);
@@ -402,12 +500,15 @@
402 500 // the site is connected — otherwise the UI shows "Connected"
403 501 // while every API call 401s.
404 502 if (($data['error'] ?? '') === 'invalid_grant') {
405 503 Google_OAuth_Proxy::mark_revoked();
504 + return;
406 505 }
407 506
408 507 // Any other failure (network blip, proxy 502) is transient;
409 - // leave the credentials alone and let the next run retry.
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();
410 511 return;
411 512 }
412 513
413 514 // Update settings with new token data
@@ -423,15 +524,79 @@
423 524 'google_refresh_token' => $data['refresh_token']
424 525 ], 'integrations');
425 526 }
426 527
528 + // A success clears any backoff a previous failure left behind.
529 + delete_transient(self::REFRESH_BACKOFF);
530 +
427 531 // Drop the memoized settings merge so subsequent reads (e.g.
428 532 // re-initializing clients) see the fresh token.
429 533 $this->merged_settings = null;
534 + } finally {
535 + $this->release_refresh_lock();
430 536 }
431 537 }
432 538
433 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 + /**
434 599 * Test all Google API connections
435 600 * Following ThinkRank test_connection patterns
436 601 *
437 602 * @return array Connection test results
@@ -436,66 +601,72 @@
436 601 *
437 602 * @return array Connection test results
438 603 */
439 604 public function test_connections(): array {
605 + // Every entry carries a `message`, configured or not: the result is
606 + // handed to the Integrations screen as JSON, and a key that exists on
607 + // some services and not others reads as `undefined` there.
440 608 $results = [
441 - 'google_analytics' => ['status' => 'not_configured'],
442 - 'search_console' => ['status' => 'not_configured'],
443 - 'pagespeed' => ['status' => 'not_configured']
609 + 'google_analytics' => ['status' => 'not_configured', 'message' => ''],
610 + 'search_console' => ['status' => 'not_configured', 'message' => ''],
611 + 'pagespeed' => ['status' => 'not_configured', 'message' => '']
444 612 ];
445 613
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 - ];
614 + // One shape for all three. They were three copies of the same block
615 + // differing only in the client, which is how the same unguarded read
616 + // came to exist in triplicate (#852).
617 + $clients = [
618 + 'google_analytics' => $this->analytics_client,
619 + 'search_console' => $this->search_console_client,
620 + 'pagespeed' => $this->pagespeed_client,
621 + ];
622 +
623 + foreach ($clients as $service => $client) {
624 + if (!$client) {
625 + continue;
460 626 }
627 +
628 + $results[$service] = $this->describe_connection_test($client);
461 629 }
462 630
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 - ];
631 + return $results;
632 + }
633 +
634 + /**
635 + * Run one client's connection test and report it in a fixed shape.
636 + *
637 + * The clients answer success with `message` and failure with `error`, and
638 + * this read `$test_result['message']` unconditionally — so a failed test
639 + * raised "Undefined array key" and handed the admin an error with
640 + * `message => null`. The reason the client had in hand was the one thing
641 + * the screen needed. The clients now set `message` on both branches; the
642 + * fallbacks here cover a client that does not, including one that answers
643 + * with neither key.
644 + *
645 + * @since 2.12.0
646 + *
647 + * @param object $client A client exposing test_connection(): array.
648 + * @return array{status:string, message:string, details?:array}
649 + */
650 + private function describe_connection_test($client): array {
651 + try {
652 + $test_result = $client->test_connection();
653 +
654 + if (!is_array($test_result)) {
655 + return ['status' => 'error', 'message' => ''];
477 656 }
478 - }
479 657
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 - }
658 + return [
659 + 'status' => !empty($test_result['success']) ? 'connected' : 'error',
660 + 'message' => (string) ($test_result['message'] ?? $test_result['error'] ?? ''),
661 + 'details' => $test_result,
662 + ];
663 + } catch (\Exception $e) {
664 + return [
665 + 'status' => 'error',
666 + 'message' => $e->getMessage(),
667 + ];
495 668 }
496 -
497 - return $results;
498 669 }
499 670
500 671 /**
501 672 * Get analytics dashboard data
@@ -545,10 +716,8 @@
545 716 // 401s are re-thrown so the token-refresh retry below runs.
546 717 if ($this->analytics_client) {
547 718 try {
548 719 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
549 - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
550 - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
551 720 } catch (\Exception $ga_error) {
552 721 if ($ga_error->getCode() === 401) {
553 722 throw $ga_error;
554 723 }
@@ -581,35 +750,21 @@
581 750 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
582 751 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
583 752 }
584 753
585 - // Calculate position distribution
586 - $position_distribution = [
587 - 'top_3' => 0,
588 - '4_10' => 0,
589 - '10_50' => 0,
590 - '51_100' => 0
591 - ];
754 + // Position distribution over every query with an
755 + // impression, not over the 1,000-row list above (#913).
756 + $position_distribution = $this->search_analytics_client
757 + ? $this->count_position_distribution($site_url, $start_date, $end_date, $search_performance['rows'] ?? [])
758 + : self::bucket_positions(
759 + $search_performance['rows'] ?? [],
760 + count($search_performance['rows'] ?? []) < 1000
761 + );
592 762
593 - foreach ($search_performance['rows'] ?? [] as $row) {
594 - $position = $row['position'] ?? 0;
595 - if ($position <= 3) {
596 - $position_distribution['top_3']++;
597 - } elseif ($position <= 10) {
598 - $position_distribution['4_10']++;
599 - } elseif ($position <= 50) {
600 - $position_distribution['10_50']++;
601 - } elseif ($position <= 100) {
602 - $position_distribution['51_100']++;
603 - }
604 - }
605 -
606 763 $dashboard_data['search_performance'] = array_merge($search_performance, [
607 764 'totals' => $totals,
608 765 'position_distribution' => $position_distribution
609 766 ]);
610 -
611 - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
612 767 } // Closing Search Console block
613 768
614 769 // If successful, break loop
615 770 break;
@@ -647,8 +802,132 @@
647 802 return $dashboard_data;
648 803 }
649 804
650 805 /**
806 + * Rows per page when counting the position distribution. The most the
807 + * Search Analytics API returns in one request.
808 + *
809 + * @since 2.15.0
810 + */
811 + private const POSITION_PAGE_SIZE = 25000;
812 +
813 + /**
814 + * Pages read before the count stops: 200,000 queries. A property past
815 + * that is counted over its top 200,000 by clicks and flagged incomplete.
816 + *
817 + * @since 2.15.0
818 + */
819 + private const POSITION_MAX_PAGES = 8;
820 +
821 + /**
822 + * Count the period's queries into position buckets across the whole
823 + * property (#913).
824 + *
825 + * The dashboard's query list is capped at 1,000 rows ordered by clicks,
826 + * so counting it told any larger site it had exactly 1,000 queries and
827 + * dropped the long tail, which is where positions 51-100 live. When that
828 + * list came back short it already holds every query and is counted as
829 + * is, with no extra request. Otherwise the property is paged with
830 + * `startRow` at POSITION_PAGE_SIZE rows until a short page, counting as
831 + * rows arrive rather than keeping them.
832 + *
833 + * A page that fails (other than a 401, which is re-thrown so the token
834 + * refresh runs) leaves the count at what was read so far, flagged
835 + * `complete: false`, rather than failing the whole dashboard.
836 + *
837 + * @since 2.15.0
838 + *
839 + * @param string $site_url Search Console property.
840 + * @param string $start_date Window start (Y-m-d).
841 + * @param string $end_date Window end (Y-m-d).
842 + * @param array $first_rows The capped query list already fetched.
843 + * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
844 + * @throws \Exception On a 401, so get_dashboard_data() can refresh the token.
845 + */
846 + private function count_position_distribution(string $site_url, string $start_date, string $end_date, array $first_rows): array {
847 + if (count($first_rows) < 1000) {
848 + return self::bucket_positions($first_rows, true);
849 + }
850 +
851 + $distribution = self::bucket_positions([], true);
852 + $start_row = 0;
853 +
854 + for ($page = 0; $page < self::POSITION_MAX_PAGES; $page++) {
855 + try {
856 + $rows = $this->search_analytics_client->get_search_analytics_data(
857 + $site_url,
858 + $start_date,
859 + $end_date,
860 + ['query'],
861 + self::POSITION_PAGE_SIZE,
862 + $start_row
863 + )['rows'] ?? [];
864 + } catch (\Exception $e) {
865 + if ($e->getCode() === 401) {
866 + throw $e;
867 + }
868 + // Nothing read yet: the capped list is the best there is.
869 + $partial = $start_row === 0 ? self::bucket_positions($first_rows, false) : $distribution;
870 + $partial['complete'] = false;
871 + return $partial;
872 + }
873 +
874 + $page_counts = self::bucket_positions($rows, true);
875 + foreach (['top_3', '4_10', '10_50', '51_100', 'over_100'] as $bucket) {
876 + $distribution[$bucket] += $page_counts[$bucket];
877 + }
878 +
879 + if (count($rows) < self::POSITION_PAGE_SIZE) {
880 + return $distribution;
881 + }
882 + $start_row += self::POSITION_PAGE_SIZE;
883 + }
884 +
885 + $distribution['complete'] = false;
886 + return $distribution;
887 + }
888 +
889 + /**
890 + * Bucket Search Console rows by average position.
891 + *
892 + * `10_50` is the historical key for positions 11-50. Rows past 100 are
893 + * counted in `over_100`: they still had impressions.
894 + *
895 + * @since 2.15.0
896 + *
897 + * @param array $rows Search Console rows.
898 + * @param bool $complete Whether $rows is every query in the window.
899 + * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
900 + */
901 + private static function bucket_positions(array $rows, bool $complete): array {
902 + $distribution = [
903 + 'top_3' => 0,
904 + '4_10' => 0,
905 + '10_50' => 0,
906 + '51_100' => 0,
907 + 'over_100' => 0,
908 + 'complete' => $complete,
909 + ];
910 +
911 + foreach ($rows as $row) {
912 + $position = (float) ($row['position'] ?? 0);
913 + if ($position <= 3) {
914 + $distribution['top_3']++;
915 + } elseif ($position <= 10) {
916 + $distribution['4_10']++;
917 + } elseif ($position <= 50) {
918 + $distribution['10_50']++;
919 + } elseif ($position <= 100) {
920 + $distribution['51_100']++;
921 + } else {
922 + $distribution['over_100']++;
923 + }
924 + }
925 +
926 + return $distribution;
927 + }
928 +
929 + /**
651 930 * Get Core Web Vitals for the analytics dashboard, cached independently
652 931 * of the dashboard payload.
653 932 *
654 933 * Successful results are cached for 1 hour; failures are never cached
@@ -807,44 +1086,8 @@
807 1086 }
808 1087 }
809 1088
810 1089 /**
811 - * Get site indexing status
812 - *
813 - * @return array Indexing status data
814 - */
815 - public function get_indexing_status(): array {
816 - $cache_key = 'indexing_status';
817 - $cached_data = get_transient($cache_key);
818 -
819 - if ($cached_data !== false) {
820 - return $cached_data;
821 - }
822 -
823 - $indexing_data = [
824 - 'status' => 'unknown',
825 - 'last_updated' => current_time('mysql')
826 - ];
827 -
828 - try {
829 - if ($this->search_console_client) {
830 - $site_url = $this->get_setting('search_console_property', get_site_url());
831 - $indexing_data = $this->search_console_client->get_indexing_status($site_url);
832 - }
833 - } catch (\Exception $e) {
834 - $indexing_data['error'] = $e->getMessage();
835 - }
836 -
837 - // Cache successful results for 1 hour. Errors are never cached, so a
838 - // transient Google failure isn't served as "no data" for a full hour.
839 - if (empty($indexing_data['error'])) {
840 - set_transient($cache_key, $indexing_data, 3600);
841 - }
842 -
843 - return $indexing_data;
844 - }
845 -
846 - /**
847 1090 * Force refresh of all cached data
848 1091 *
849 1092 * @return array Refresh results
850 1093 */
@@ -861,11 +1104,8 @@
861 1104 'analytics_dashboard_v5_180d',
862 1105 'seo_opportunities_7d',
863 1106 'seo_opportunities_30d',
864 1107 'seo_opportunities_90d',
865 - 'seo_insights_7d',
866 - 'seo_insights_30d',
867 - 'seo_insights_90d',
868 1108 'indexing_status',
869 1109 'thinkrank_dashboard_cwv'
870 1110 ];
871 1111
@@ -933,411 +1173,6 @@
933 1173 */
934 1174 public function cleanup_cache(): void {
935 1175 // WordPress handles transient cleanup automatically
936 1176 // This method is for future custom cache cleanup if needed
937 - }
938 -
939 - // ========================================
940 - // SEO Intelligence Enhancement Methods
941 - // ========================================
942 -
943 - /**
944 - * Get intelligent dashboard data with trends and insights
945 - *
946 - * @param string $date_range Date range for analysis
947 - * @return array Enhanced dashboard data with intelligence
948 - */
949 - public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
950 - // Get base dashboard data
951 - $dashboard_data = $this->get_dashboard_data($date_range);
952 -
953 - // Check if there's an error in the data
954 - if (isset($dashboard_data['error'])) {
955 - return [
956 - 'success' => false,
957 - 'data' => null,
958 - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
959 - 'timestamp' => current_time('mysql')
960 - ];
961 - }
962 -
963 - // Check if we have real data available
964 - if (!$this->has_real_data($dashboard_data)) {
965 - return [
966 - 'success' => false,
967 - 'data' => null,
968 - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
969 - 'timestamp' => current_time('mysql')
970 - ];
971 - }
972 -
973 - // Intelligence engine is a Pro-only feature. The four SEO_* classes ship
974 - // in Free too (the PSR-4 autoloader would resolve them), so class_exists()
975 - // can't gate this — check the real Pro signal instead.
976 - if (!Plan_Config::is_pro()) {
977 - return [
978 - 'success' => false,
979 - 'data' => null,
980 - 'message' => 'Intelligent dashboard requires ThinkRank Pro.',
981 - 'timestamp' => current_time('mysql')
982 - ];
983 - }
984 -
985 - $trend_analyzer = new SEO_Trend_Analyzer();
986 - $scoring_engine = new SEO_Scoring_Engine();
987 - $insight_generator = new SEO_Insight_Generator();
988 -
989 - $data = $dashboard_data;
990 -
991 - // Generate trend analysis
992 - $current_data = $data;
993 - $historical_data = $this->get_historical_data($date_range);
994 -
995 - $trends = [
996 - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
997 - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
998 - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
999 - ];
1000 -
1001 - // Calculate SEO health score
1002 - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
1003 -
1004 - // Generate insights
1005 - $insights = [
1006 - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
1007 - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
1008 - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
1009 - ];
1010 -
1011 - // Combine all intelligence data
1012 - $enhanced_data = array_merge($data, [
1013 - 'intelligence' => [
1014 - 'trends' => $trends,
1015 - 'seo_health_score' => $seo_health,
1016 - 'insights' => $insights,
1017 - 'last_analyzed' => current_time('mysql')
1018 - ]
1019 - ]);
1020 -
1021 - return [
1022 - 'success' => true,
1023 - 'data' => $enhanced_data,
1024 - 'message' => 'Intelligent dashboard data retrieved successfully'
1025 - ];
1026 - }
1027 -
1028 - /**
1029 - * Get intelligent SEO opportunities with prioritization
1030 - *
1031 - * @param string $date_range Date range for analysis
1032 - * @return array Enhanced opportunities with intelligence
1033 - */
1034 - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
1035 - // Get base opportunities data
1036 - $opportunities_data = $this->get_seo_opportunities($date_range);
1037 -
1038 - // Check if there's an error in the data
1039 - if (isset($opportunities_data['error'])) {
1040 - return [
1041 - 'success' => false,
1042 - 'data' => null,
1043 - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
1044 - 'timestamp' => current_time('mysql')
1045 - ];
1046 - }
1047 -
1048 - // The opportunities payload itself has no search_performance key — that
1049 - // data lives in the (cached) dashboard payload. Pull it from there both
1050 - // for the availability check and as input for the opportunity detectors;
1051 - // checking $opportunities_data['search_performance'] here used to make
1052 - // this method always bail with "No Search Console data available".
1053 - $dashboard_data = $this->get_dashboard_data($date_range);
1054 - $search_performance = $dashboard_data['search_performance'] ?? [];
1055 - $opportunities_data['search_performance'] = $search_performance;
1056 -
1057 - $has_search_data = !empty($search_performance['rows']) ||
1058 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1059 - ($search_performance['total_impressions'] ?? 0) > 0;
1060 -
1061 - if (!$has_search_data) {
1062 - return [
1063 - 'success' => false,
1064 - 'data' => null,
1065 - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
1066 - 'timestamp' => current_time('mysql')
1067 - ];
1068 - }
1069 -
1070 - // Intelligence engine is a Pro-only feature — gate on the real Pro signal,
1071 - // not class_exists() (the classes ship in Free and would autoload).
1072 - if (!Plan_Config::is_pro()) {
1073 - return [
1074 - 'success' => false,
1075 - 'data' => null,
1076 - 'message' => 'Intelligent opportunities require ThinkRank Pro.',
1077 - 'timestamp' => current_time('mysql')
1078 - ];
1079 - }
1080 -
1081 - $opportunity_detector = new SEO_Opportunity_Detector();
1082 - $scoring_engine = new SEO_Scoring_Engine();
1083 -
1084 - $data = $opportunities_data;
1085 -
1086 - // Detect intelligent opportunities
1087 - $search_console_data = $data['search_performance'] ?? [];
1088 - $analytics_data = $data;
1089 -
1090 - $intelligent_opportunities = [
1091 - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
1092 - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
1093 - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
1094 - ];
1095 -
1096 - // Prioritize all opportunities. prioritize_opportunities() expects
1097 - // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list.
1098 - $opportunities_by_category = [
1099 - 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
1100 - 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
1101 - 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [],
1102 - ];
1103 - $all_opportunities = array_merge(...array_values($opportunities_by_category));
1104 -
1105 - $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category);
1106 - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
1107 -
1108 - // Enhance original data with intelligence
1109 - $enhanced_data = array_merge($data, [
1110 - 'intelligent_opportunities' => $intelligent_opportunities,
1111 - 'prioritized_opportunities' => $prioritized,
1112 - 'impact_effort_matrix' => $impact_matrix,
1113 - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
1114 - 'last_analyzed' => current_time('mysql')
1115 - ]);
1116 -
1117 - return [
1118 - 'success' => true,
1119 - 'data' => $enhanced_data,
1120 - 'message' => 'Intelligent SEO opportunities retrieved successfully'
1121 - ];
1122 - }
1123 -
1124 - /**
1125 - * Get SEO performance insights
1126 - *
1127 - * @param string $date_range Date range for analysis
1128 - * @return array SEO insights data
1129 - */
1130 - public function get_seo_insights(string $date_range = '30d'): array {
1131 - $cache_key = "seo_insights_{$date_range}";
1132 - $cached_data = get_transient($cache_key);
1133 -
1134 - if ($cached_data !== false) {
1135 - return [
1136 - 'success' => true,
1137 - 'data' => $cached_data,
1138 - 'cached' => true,
1139 - 'message' => 'SEO insights retrieved from cache'
1140 - ];
1141 - }
1142 -
1143 - try {
1144 - // Get dashboard data for analysis
1145 - $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
1146 -
1147 - if (!$dashboard_result['success']) {
1148 - return $dashboard_result;
1149 - }
1150 -
1151 - $dashboard_data = $dashboard_result['data'];
1152 - $intelligence = $dashboard_data['intelligence'] ?? [];
1153 -
1154 - // Insights are a Pro-only feature — gate on the real Pro signal,
1155 - // not class_exists() (SEO_Insight_Generator ships in Free too).
1156 - if (!Plan_Config::is_pro()) {
1157 - return [
1158 - 'success' => false,
1159 - 'data' => null,
1160 - 'message' => 'SEO insights require ThinkRank Pro.',
1161 - 'timestamp' => current_time('mysql')
1162 - ];
1163 - }
1164 -
1165 - $insight_generator = new SEO_Insight_Generator();
1166 -
1167 - // Collect all insights
1168 - $all_insights = [];
1169 -
1170 - if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
1171 - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
1172 - }
1173 -
1174 - if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
1175 - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
1176 - }
1177 -
1178 - if (!empty($intelligence['insights']['content_insights']['insights'])) {
1179 - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
1180 - }
1181 -
1182 - // Format and prioritize insights
1183 - $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
1184 - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
1185 -
1186 - $insights_data = [
1187 - 'insights' => $prioritized_insights['prioritized_insights'],
1188 - 'summary' => [
1189 - 'total_insights' => count($formatted_insights),
1190 - 'high_impact_count' => $prioritized_insights['high_impact_count'],
1191 - 'action_required_count' => $prioritized_insights['action_required_count']
1192 - ],
1193 - 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
1194 - 'generated_at' => current_time('mysql')
1195 - ];
1196 -
1197 - // Cache the results
1198 - set_transient($cache_key, $insights_data, $this->cache_duration);
1199 -
1200 - return [
1201 - 'success' => true,
1202 - 'data' => $insights_data,
1203 - 'cached' => false,
1204 - 'message' => 'SEO insights generated successfully'
1205 - ];
1206 - } catch (\Exception $e) {
1207 - return [
1208 - 'success' => false,
1209 - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
1210 - 'data' => null
1211 - ];
1212 - }
1213 - }
1214 -
1215 - /**
1216 - * Check if real analytics data is available
1217 - *
1218 - * @param array $dashboard_data Dashboard data to check
1219 - * @return bool True if real data is available
1220 - */
1221 - private function has_real_data(array $dashboard_data): bool {
1222 - // Check if we have meaningful traffic data
1223 - $traffic = $dashboard_data['traffic'] ?? [];
1224 - $search_performance = $dashboard_data['search_performance'] ?? [];
1225 -
1226 - $has_traffic = !empty($traffic) && (
1227 - ($traffic['sessions'] ?? 0) > 0 ||
1228 - ($traffic['pageviews'] ?? 0) > 0 ||
1229 - ($traffic['active_users'] ?? 0) > 0
1230 - );
1231 -
1232 - $has_search_data = !empty($search_performance) && (
1233 - !empty($search_performance['rows']) ||
1234 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1235 - ($search_performance['total_impressions'] ?? 0) > 0
1236 - );
1237 -
1238 - return $has_traffic || $has_search_data;
1239 - }
1240 -
1241 - /**
1242 - * Get historical data for trend comparison
1243 - *
1244 - * @param string $current_range Current date range
1245 - * @return array Historical data
1246 - */
1247 - private function get_historical_data(string $current_range): array {
1248 - // Calculate previous period based on current range
1249 - $previous_range = $this->calculate_previous_period($current_range);
1250 -
1251 - // Try to get actual historical data from previous period
1252 - $historical_data = $this->get_dashboard_data($previous_range);
1253 -
1254 - // Return the actual historical data (may be empty if no real data available)
1255 - return [
1256 - 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
1257 - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
1258 - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
1259 - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
1260 - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
1261 - ];
1262 - }
1263 -
1264 - /**
1265 - * Calculate previous period for comparison
1266 - *
1267 - * @param string $current_range Current range
1268 - * @return string Previous period range
1269 - */
1270 - private function calculate_previous_period(string $current_range): string {
1271 - // Simple mapping for now - could be enhanced with actual date calculations
1272 - $period_mapping = [
1273 - '7d' => '14d',
1274 - '30d' => '60d',
1275 - '90d' => '180d'
1276 - ];
1277 -
1278 - return $period_mapping[$current_range] ?? '60d';
1279 - }
1280 -
1281 - /**
1282 - * Generate opportunity summary
1283 - *
1284 - * @param array $opportunities All opportunities
1285 - * @return array Opportunity summary
1286 - */
1287 - private function generate_opportunity_summary(array $opportunities): array {
1288 - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
1289 - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
1290 - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
1291 -
1292 - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
1293 -
1294 - $potential_clicks = 0;
1295 - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
1296 - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
1297 - }
1298 -
1299 - return [
1300 - 'total_opportunities' => $total_opportunities,
1301 - 'quick_wins_count' => $quick_wins_count,
1302 - 'content_opportunities_count' => $content_opportunities_count,
1303 - 'keyword_opportunities_count' => $keyword_opportunities_count,
1304 - 'potential_additional_clicks' => $potential_clicks,
1305 - 'priority_recommendation' => $quick_wins_count > 0 ?
1306 - 'Focus on quick wins first for immediate impact' :
1307 - 'Focus on content optimization for long-term growth'
1308 - ];
1309 - }
1310 -
1311 - /**
1312 - * Clear intelligence cache
1313 - *
1314 - * @return array Clear result
1315 - */
1316 - public function clear_intelligence_cache(): array {
1317 - $intelligence_cache_keys = [
1318 - 'seo_insights_7d',
1319 - 'seo_insights_30d',
1320 - 'seo_insights_90d',
1321 - 'intelligent_dashboard_7d',
1322 - 'intelligent_dashboard_30d',
1323 - 'intelligent_dashboard_90d',
1324 - 'intelligent_opportunities_7d',
1325 - 'intelligent_opportunities_30d',
1326 - 'intelligent_opportunities_90d'
1327 - ];
1328 -
1329 - $cleared = 0;
1330 - foreach ($intelligence_cache_keys as $key) {
1331 - if (delete_transient($key)) {
1332 - $cleared++;
1333 - }
1334 - }
1335 -
1336 - return [
1337 - 'success' => true,
1338 - 'message' => "Cleared {$cleared} intelligence cache entries",
1339 - 'cleared_count' => $cleared,
1340 - 'timestamp' => current_time('mysql')
1341 - ];
1342 1177 }
1343 1178 }