| @@ -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. |