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 +249 -84 2.7.0 → 2.14.2 View file →
@@ -124,8 +124,45 @@
124 124 */
125 125 private const REFRESH_TIMEOUT = 10;
126 126
127 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 + /**
128 165 * Constructor
129 166 *
130 167 * @param Settings_Manager|null $settings_manager Settings manager instance
131 168 */
@@ -601,66 +638,72 @@
601 638 *
602 639 * @return array Connection test results
603 640 */
604 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.
605 645 $results = [
606 - 'google_analytics' => ['status' => 'not_configured'],
607 - 'search_console' => ['status' => 'not_configured'],
608 - 'pagespeed' => ['status' => 'not_configured']
646 + 'google_analytics' => ['status' => 'not_configured', 'message' => ''],
647 + 'search_console' => ['status' => 'not_configured', 'message' => ''],
648 + 'pagespeed' => ['status' => 'not_configured', 'message' => '']
609 649 ];
610 650
611 - // Test Google Analytics connection
612 - if ($this->analytics_client) {
613 - try {
614 - $test_result = $this->analytics_client->test_connection();
615 - $results['google_analytics'] = [
616 - 'status' => $test_result['success'] ? 'connected' : 'error',
617 - 'message' => $test_result['message'],
618 - 'details' => $test_result
619 - ];
620 - } catch (\Exception $e) {
621 - $results['google_analytics'] = [
622 - 'status' => 'error',
623 - 'message' => $e->getMessage()
624 - ];
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;
625 663 }
664 +
665 + $results[$service] = $this->describe_connection_test($client);
626 666 }
627 667
628 - // Test Search Console connection
629 - if ($this->search_console_client) {
630 - try {
631 - $test_result = $this->search_console_client->test_connection();
632 - $results['search_console'] = [
633 - 'status' => $test_result['success'] ? 'connected' : 'error',
634 - 'message' => $test_result['message'],
635 - 'details' => $test_result
636 - ];
637 - } catch (\Exception $e) {
638 - $results['search_console'] = [
639 - 'status' => 'error',
640 - 'message' => $e->getMessage()
641 - ];
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' => ''];
642 693 }
643 - }
644 694
645 - // Test PageSpeed connection
646 - if ($this->pagespeed_client) {
647 - try {
648 - $test_result = $this->pagespeed_client->test_connection();
649 - $results['pagespeed'] = [
650 - 'status' => $test_result['success'] ? 'connected' : 'error',
651 - 'message' => $test_result['message'],
652 - 'details' => $test_result
653 - ];
654 - } catch (\Exception $e) {
655 - $results['pagespeed'] = [
656 - 'status' => 'error',
657 - 'message' => $e->getMessage()
658 - ];
659 - }
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 + ];
660 705 }
661 -
662 - return $results;
663 706 }
664 707
665 708 /**
666 709 * Get analytics dashboard data
@@ -671,9 +714,9 @@
671 714 *
672 715 * @throws \Exception On failure.
673 716 */
674 717 public function get_dashboard_data(string $date_range = '30d'): array {
675 - $cache_key = "analytics_dashboard_v5_{$date_range}";
718 + $cache_key = self::DASHBOARD_CACHE_PREFIX . $date_range;
676 719 $cached_data = get_transient($cache_key);
677 720
678 721 if ($cached_data !== false) {
679 722 // Core Web Vitals are cached separately with a much shorter
@@ -744,29 +787,17 @@
744 787 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
745 788 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
746 789 }
747 790
748 - // Calculate position distribution
749 - $position_distribution = [
750 - 'top_3' => 0,
751 - '4_10' => 0,
752 - '10_50' => 0,
753 - '51_100' => 0
754 - ];
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 + );
755 799
756 - foreach ($search_performance['rows'] ?? [] as $row) {
757 - $position = $row['position'] ?? 0;
758 - if ($position <= 3) {
759 - $position_distribution['top_3']++;
760 - } elseif ($position <= 10) {
761 - $position_distribution['4_10']++;
762 - } elseif ($position <= 50) {
763 - $position_distribution['10_50']++;
764 - } elseif ($position <= 100) {
765 - $position_distribution['51_100']++;
766 - }
767 - }
768 -
769 800 $dashboard_data['search_performance'] = array_merge($search_performance, [
770 801 'totals' => $totals,
771 802 'position_distribution' => $position_distribution
772 803 ]);
@@ -808,8 +839,132 @@
808 839 return $dashboard_data;
809 840 }
810 841
811 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 + /**
812 967 * Get Core Web Vitals for the analytics dashboard, cached independently
813 968 * of the dashboard payload.
814 969 *
815 970 * Successful results are cached for 1 hour; failures are never cached
@@ -852,9 +1007,9 @@
852 1007 * @param string $date_range Date range for analysis
853 1008 * @return array SEO opportunities
854 1009 */
855 1010 public function get_seo_opportunities(string $date_range = '30d'): array {
856 - $cache_key = "seo_opportunities_{$date_range}";
1011 + $cache_key = self::OPPORTUNITIES_CACHE_PREFIX . $date_range;
857 1012 $cached_data = get_transient($cache_key);
858 1013
859 1014 if ($cached_data !== false) {
860 1015 return $cached_data;
@@ -968,8 +1123,27 @@
968 1123 }
969 1124 }
970 1125
971 1126 /**
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.
1130 + *
1131 + * @since 2.14.2
1132 + * @return string[]
1133 + */
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;
1138 + }
1139 + foreach (self::CACHED_OPPORTUNITY_RANGES as $range) {
1140 + $keys[] = self::OPPORTUNITIES_CACHE_PREFIX . $range;
1141 + }
1142 + return $keys;
1143 + }
1144 +
1145 + /**
972 1146 * Force refresh of all cached data
973 1147 *
974 1148 * @return array Refresh results
975 1149 */
@@ -976,21 +1150,12 @@
976 1150 public function refresh_data(): array {
977 1151 // Clear all analytics-related transients, including the previous-period
978 1152 // ranges used for trend comparison (14d/60d/180d) and the separately
979 1153 // cached Core Web Vitals payload.
980 - $cache_keys = [
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',
987 - 'seo_opportunities_7d',
988 - 'seo_opportunities_30d',
989 - 'seo_opportunities_90d',
990 - 'indexing_status',
991 - 'thinkrank_dashboard_cwv'
992 - ];
1154 + $cache_keys = array_merge(
1155 + self::dashboard_cache_keys(),
1156 + ['indexing_status', 'thinkrank_dashboard_cwv']
1157 + );
993 1158
994 1159 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
995 1160 // URL + device, so compute them for the URL/device combinations the
996 1161 // plugin actually tests.