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.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 +132 -20 2.13.0 → 2.14.1 View file →
@@ -750,29 +750,17 @@
750 750 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
751 751 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
752 752 }
753 753
754 - // Calculate position distribution
755 - $position_distribution = [
756 - 'top_3' => 0,
757 - '4_10' => 0,
758 - '10_50' => 0,
759 - '51_100' => 0
760 - ];
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 + );
761 762
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 763 $dashboard_data['search_performance'] = array_merge($search_performance, [
776 764 'totals' => $totals,
777 765 'position_distribution' => $position_distribution
778 766 ]);
@@ -811,8 +799,132 @@
811 799 // long-lived GSC payload has been stored.
812 800 $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
813 801
814 802 return $dashboard_data;
803 + }
804 +
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;
815 927 }
816 928
817 929 /**
818 930 * Get Core Web Vitals for the analytics dashboard, cached independently