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 +187 -69 2.9.0 → 2.14.1 View file →
@@ -601,66 +601,72 @@
601 601 *
602 602 * @return array Connection test results
603 603 */
604 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.
605 608 $results = [
606 - 'google_analytics' => ['status' => 'not_configured'],
607 - 'search_console' => ['status' => 'not_configured'],
608 - 'pagespeed' => ['status' => 'not_configured']
609 + 'google_analytics' => ['status' => 'not_configured', 'message' => ''],
610 + 'search_console' => ['status' => 'not_configured', 'message' => ''],
611 + 'pagespeed' => ['status' => 'not_configured', 'message' => '']
609 612 ];
610 613
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 - ];
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;
625 626 }
627 +
628 + $results[$service] = $this->describe_connection_test($client);
626 629 }
627 630
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 - ];
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' => ''];
642 656 }
643 - }
644 657
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 - }
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 + ];
660 668 }
661 -
662 - return $results;
663 669 }
664 670
665 671 /**
666 672 * Get analytics dashboard data
@@ -744,29 +750,17 @@
744 750 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
745 751 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
746 752 }
747 753
748 - // Calculate position distribution
749 - $position_distribution = [
750 - 'top_3' => 0,
751 - '4_10' => 0,
752 - '10_50' => 0,
753 - '51_100' => 0
754 - ];
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 + );
755 762
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 763 $dashboard_data['search_performance'] = array_merge($search_performance, [
770 764 'totals' => $totals,
771 765 'position_distribution' => $position_distribution
772 766 ]);
@@ -805,8 +799,132 @@
805 799 // long-lived GSC payload has been stored.
806 800 $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
807 801
808 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;
809 927 }
810 928
811 929 /**
812 930 * Get Core Web Vitals for the analytics dashboard, cached independently