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 +194 -35 2.14.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 */
@@ -677,9 +714,9 @@
677 714 *
678 715 * @throws \Exception On failure.
679 716 */
680 717 public function get_dashboard_data(string $date_range = '30d'): array {
681 - $cache_key = "analytics_dashboard_v5_{$date_range}";
718 + $cache_key = self::DASHBOARD_CACHE_PREFIX . $date_range;
682 719 $cached_data = get_transient($cache_key);
683 720
684 721 if ($cached_data !== false) {
685 722 // Core Web Vitals are cached separately with a much shorter
@@ -750,29 +787,17 @@
750 787 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
751 788 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
752 789 }
753 790
754 - // Calculate position distribution
755 - $position_distribution = [
756 - 'top_3' => 0,
757 - '4_10' => 0,
758 - '10_50' => 0,
759 - '51_100' => 0
760 - ];
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 + );
761 799
762 - foreach ($search_performance['rows'] ?? [] as $row) {
763 - $position = $row['position'] ?? 0;
764 - if ($position <= 3) {
765 - $position_distribution['top_3']++;
766 - } elseif ($position <= 10) {
767 - $position_distribution['4_10']++;
768 - } elseif ($position <= 50) {
769 - $position_distribution['10_50']++;
770 - } elseif ($position <= 100) {
771 - $position_distribution['51_100']++;
772 - }
773 - }
774 -
775 800 $dashboard_data['search_performance'] = array_merge($search_performance, [
776 801 'totals' => $totals,
777 802 'position_distribution' => $position_distribution
778 803 ]);
@@ -814,8 +839,132 @@
814 839 return $dashboard_data;
815 840 }
816 841
817 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 + /**
818 967 * Get Core Web Vitals for the analytics dashboard, cached independently
819 968 * of the dashboard payload.
820 969 *
821 970 * Successful results are cached for 1 hour; failures are never cached
@@ -858,9 +1007,9 @@
858 1007 * @param string $date_range Date range for analysis
859 1008 * @return array SEO opportunities
860 1009 */
861 1010 public function get_seo_opportunities(string $date_range = '30d'): array {
862 - $cache_key = "seo_opportunities_{$date_range}";
1011 + $cache_key = self::OPPORTUNITIES_CACHE_PREFIX . $date_range;
863 1012 $cached_data = get_transient($cache_key);
864 1013
865 1014 if ($cached_data !== false) {
866 1015 return $cached_data;
@@ -974,8 +1123,27 @@
974 1123 }
975 1124 }
976 1125
977 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 + /**
978 1146 * Force refresh of all cached data
979 1147 *
980 1148 * @return array Refresh results
981 1149 */
@@ -982,21 +1150,12 @@
982 1150 public function refresh_data(): array {
983 1151 // Clear all analytics-related transients, including the previous-period
984 1152 // ranges used for trend comparison (14d/60d/180d) and the separately
985 1153 // cached Core Web Vitals payload.
986 - $cache_keys = [
987 - 'analytics_dashboard_v5_7d',
988 - 'analytics_dashboard_v5_30d',
989 - 'analytics_dashboard_v5_90d',
990 - 'analytics_dashboard_v5_14d',
991 - 'analytics_dashboard_v5_60d',
992 - 'analytics_dashboard_v5_180d',
993 - 'seo_opportunities_7d',
994 - 'seo_opportunities_30d',
995 - 'seo_opportunities_90d',
996 - 'indexing_status',
997 - 'thinkrank_dashboard_cwv'
998 - ];
1154 + $cache_keys = array_merge(
1155 + self::dashboard_cache_keys(),
1156 + ['indexing_status', 'thinkrank_dashboard_cwv']
1157 + );
999 1158
1000 1159 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
1001 1160 // URL + device, so compute them for the URL/device combinations the
1002 1161 // plugin actually tests.