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 +242 -526 2.3.0 → 2.14.2 View file →
@@ -16,9 +16,8 @@
16 16
17 17 namespace ThinkRank\SEO;
18 18
19 19 use ThinkRank\Core\Settings_Manager;
20 -use ThinkRank\Core\Plan_Config;
21 20 use ThinkRank\Integrations\Google_Analytics_Client;
22 21 use ThinkRank\Integrations\Google_Search_Console_Client;
23 22 use ThinkRank\Integrations\Google_PageSpeed_Client;
24 23 use ThinkRank\Integrations\Google_Search_Analytics_Client;
@@ -125,8 +124,45 @@
125 124 */
126 125 private const REFRESH_TIMEOUT = 10;
127 126
128 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 + /**
129 165 * Constructor
130 166 *
131 167 * @param Settings_Manager|null $settings_manager Settings manager instance
132 168 */
@@ -602,66 +638,72 @@
602 638 *
603 639 * @return array Connection test results
604 640 */
605 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.
606 645 $results = [
607 - 'google_analytics' => ['status' => 'not_configured'],
608 - 'search_console' => ['status' => 'not_configured'],
609 - 'pagespeed' => ['status' => 'not_configured']
646 + 'google_analytics' => ['status' => 'not_configured', 'message' => ''],
647 + 'search_console' => ['status' => 'not_configured', 'message' => ''],
648 + 'pagespeed' => ['status' => 'not_configured', 'message' => '']
610 649 ];
611 650
612 - // Test Google Analytics connection
613 - if ($this->analytics_client) {
614 - try {
615 - $test_result = $this->analytics_client->test_connection();
616 - $results['google_analytics'] = [
617 - 'status' => $test_result['success'] ? 'connected' : 'error',
618 - 'message' => $test_result['message'],
619 - 'details' => $test_result
620 - ];
621 - } catch (\Exception $e) {
622 - $results['google_analytics'] = [
623 - 'status' => 'error',
624 - 'message' => $e->getMessage()
625 - ];
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;
626 663 }
664 +
665 + $results[$service] = $this->describe_connection_test($client);
627 666 }
628 667
629 - // Test Search Console connection
630 - if ($this->search_console_client) {
631 - try {
632 - $test_result = $this->search_console_client->test_connection();
633 - $results['search_console'] = [
634 - 'status' => $test_result['success'] ? 'connected' : 'error',
635 - 'message' => $test_result['message'],
636 - 'details' => $test_result
637 - ];
638 - } catch (\Exception $e) {
639 - $results['search_console'] = [
640 - 'status' => 'error',
641 - 'message' => $e->getMessage()
642 - ];
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' => ''];
643 693 }
644 - }
645 694
646 - // Test PageSpeed connection
647 - if ($this->pagespeed_client) {
648 - try {
649 - $test_result = $this->pagespeed_client->test_connection();
650 - $results['pagespeed'] = [
651 - 'status' => $test_result['success'] ? 'connected' : 'error',
652 - 'message' => $test_result['message'],
653 - 'details' => $test_result
654 - ];
655 - } catch (\Exception $e) {
656 - $results['pagespeed'] = [
657 - 'status' => 'error',
658 - 'message' => $e->getMessage()
659 - ];
660 - }
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 + ];
661 705 }
662 -
663 - return $results;
664 706 }
665 707
666 708 /**
667 709 * Get analytics dashboard data
@@ -672,9 +714,9 @@
672 714 *
673 715 * @throws \Exception On failure.
674 716 */
675 717 public function get_dashboard_data(string $date_range = '30d'): array {
676 - $cache_key = "analytics_dashboard_v5_{$date_range}";
718 + $cache_key = self::DASHBOARD_CACHE_PREFIX . $date_range;
677 719 $cached_data = get_transient($cache_key);
678 720
679 721 if ($cached_data !== false) {
680 722 // Core Web Vitals are cached separately with a much shorter
@@ -711,10 +753,8 @@
711 753 // 401s are re-thrown so the token-refresh retry below runs.
712 754 if ($this->analytics_client) {
713 755 try {
714 756 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
715 - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
716 - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
717 757 } catch (\Exception $ga_error) {
718 758 if ($ga_error->getCode() === 401) {
719 759 throw $ga_error;
720 760 }
@@ -747,35 +787,21 @@
747 787 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
748 788 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
749 789 }
750 790
751 - // Calculate position distribution
752 - $position_distribution = [
753 - 'top_3' => 0,
754 - '4_10' => 0,
755 - '10_50' => 0,
756 - '51_100' => 0
757 - ];
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 + );
758 799
759 - foreach ($search_performance['rows'] ?? [] as $row) {
760 - $position = $row['position'] ?? 0;
761 - if ($position <= 3) {
762 - $position_distribution['top_3']++;
763 - } elseif ($position <= 10) {
764 - $position_distribution['4_10']++;
765 - } elseif ($position <= 50) {
766 - $position_distribution['10_50']++;
767 - } elseif ($position <= 100) {
768 - $position_distribution['51_100']++;
769 - }
770 - }
771 -
772 800 $dashboard_data['search_performance'] = array_merge($search_performance, [
773 801 'totals' => $totals,
774 802 'position_distribution' => $position_distribution
775 803 ]);
776 -
777 - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
778 804 } // Closing Search Console block
779 805
780 806 // If successful, break loop
781 807 break;
@@ -813,8 +839,132 @@
813 839 return $dashboard_data;
814 840 }
815 841
816 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 + /**
817 967 * Get Core Web Vitals for the analytics dashboard, cached independently
818 968 * of the dashboard payload.
819 969 *
820 970 * Successful results are cached for 1 hour; failures are never cached
@@ -857,9 +1007,9 @@
857 1007 * @param string $date_range Date range for analysis
858 1008 * @return array SEO opportunities
859 1009 */
860 1010 public function get_seo_opportunities(string $date_range = '30d'): array {
861 - $cache_key = "seo_opportunities_{$date_range}";
1011 + $cache_key = self::OPPORTUNITIES_CACHE_PREFIX . $date_range;
862 1012 $cached_data = get_transient($cache_key);
863 1013
864 1014 if ($cached_data !== false) {
865 1015 return $cached_data;
@@ -973,41 +1123,24 @@
973 1123 }
974 1124 }
975 1125
976 1126 /**
977 - * Get site indexing status
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.
978 1130 *
979 - * @return array Indexing status data
1131 + * @since 2.14.2
1132 + * @return string[]
980 1133 */
981 - public function get_indexing_status(): array {
982 - $cache_key = 'indexing_status';
983 - $cached_data = get_transient($cache_key);
984 -
985 - if ($cached_data !== false) {
986 - return $cached_data;
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;
987 1138 }
988 -
989 - $indexing_data = [
990 - 'status' => 'unknown',
991 - 'last_updated' => current_time('mysql')
992 - ];
993 -
994 - try {
995 - if ($this->search_console_client) {
996 - $site_url = $this->get_setting('search_console_property', get_site_url());
997 - $indexing_data = $this->search_console_client->get_indexing_status($site_url);
998 - }
999 - } catch (\Exception $e) {
1000 - $indexing_data['error'] = $e->getMessage();
1139 + foreach (self::CACHED_OPPORTUNITY_RANGES as $range) {
1140 + $keys[] = self::OPPORTUNITIES_CACHE_PREFIX . $range;
1001 1141 }
1002 -
1003 - // Cache successful results for 1 hour. Errors are never cached, so a
1004 - // transient Google failure isn't served as "no data" for a full hour.
1005 - if (empty($indexing_data['error'])) {
1006 - set_transient($cache_key, $indexing_data, 3600);
1007 - }
1008 -
1009 - return $indexing_data;
1142 + return $keys;
1010 1143 }
1011 1144
1012 1145 /**
1013 1146 * Force refresh of all cached data
@@ -1017,24 +1150,12 @@
1017 1150 public function refresh_data(): array {
1018 1151 // Clear all analytics-related transients, including the previous-period
1019 1152 // ranges used for trend comparison (14d/60d/180d) and the separately
1020 1153 // cached Core Web Vitals payload.
1021 - $cache_keys = [
1022 - 'analytics_dashboard_v5_7d',
1023 - 'analytics_dashboard_v5_30d',
1024 - 'analytics_dashboard_v5_90d',
1025 - 'analytics_dashboard_v5_14d',
1026 - 'analytics_dashboard_v5_60d',
1027 - 'analytics_dashboard_v5_180d',
1028 - 'seo_opportunities_7d',
1029 - 'seo_opportunities_30d',
1030 - 'seo_opportunities_90d',
1031 - 'seo_insights_7d',
1032 - 'seo_insights_30d',
1033 - 'seo_insights_90d',
1034 - 'indexing_status',
1035 - 'thinkrank_dashboard_cwv'
1036 - ];
1154 + $cache_keys = array_merge(
1155 + self::dashboard_cache_keys(),
1156 + ['indexing_status', 'thinkrank_dashboard_cwv']
1157 + );
1037 1158
1038 1159 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
1039 1160 // URL + device, so compute them for the URL/device combinations the
1040 1161 // plugin actually tests.
@@ -1099,411 +1220,6 @@
1099 1220 */
1100 1221 public function cleanup_cache(): void {
1101 1222 // WordPress handles transient cleanup automatically
1102 1223 // This method is for future custom cache cleanup if needed
1103 - }
1104 -
1105 - // ========================================
1106 - // SEO Intelligence Enhancement Methods
1107 - // ========================================
1108 -
1109 - /**
1110 - * Get intelligent dashboard data with trends and insights
1111 - *
1112 - * @param string $date_range Date range for analysis
1113 - * @return array Enhanced dashboard data with intelligence
1114 - */
1115 - public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
1116 - // Get base dashboard data
1117 - $dashboard_data = $this->get_dashboard_data($date_range);
1118 -
1119 - // Check if there's an error in the data
1120 - if (isset($dashboard_data['error'])) {
1121 - return [
1122 - 'success' => false,
1123 - 'data' => null,
1124 - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
1125 - 'timestamp' => current_time('mysql')
1126 - ];
1127 - }
1128 -
1129 - // Check if we have real data available
1130 - if (!$this->has_real_data($dashboard_data)) {
1131 - return [
1132 - 'success' => false,
1133 - 'data' => null,
1134 - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
1135 - 'timestamp' => current_time('mysql')
1136 - ];
1137 - }
1138 -
1139 - // Intelligence engine is a Pro-only feature. The four SEO_* classes ship
1140 - // in Free too (the PSR-4 autoloader would resolve them), so class_exists()
1141 - // can't gate this — check the real Pro signal instead.
1142 - if (!Plan_Config::is_pro()) {
1143 - return [
1144 - 'success' => false,
1145 - 'data' => null,
1146 - 'message' => 'Intelligent dashboard requires ThinkRank Pro.',
1147 - 'timestamp' => current_time('mysql')
1148 - ];
1149 - }
1150 -
1151 - $trend_analyzer = new SEO_Trend_Analyzer();
1152 - $scoring_engine = new SEO_Scoring_Engine();
1153 - $insight_generator = new SEO_Insight_Generator();
1154 -
1155 - $data = $dashboard_data;
1156 -
1157 - // Generate trend analysis
1158 - $current_data = $data;
1159 - $historical_data = $this->get_historical_data($date_range);
1160 -
1161 - $trends = [
1162 - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
1163 - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
1164 - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
1165 - ];
1166 -
1167 - // Calculate SEO health score
1168 - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
1169 -
1170 - // Generate insights
1171 - $insights = [
1172 - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
1173 - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
1174 - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
1175 - ];
1176 -
1177 - // Combine all intelligence data
1178 - $enhanced_data = array_merge($data, [
1179 - 'intelligence' => [
1180 - 'trends' => $trends,
1181 - 'seo_health_score' => $seo_health,
1182 - 'insights' => $insights,
1183 - 'last_analyzed' => current_time('mysql')
1184 - ]
1185 - ]);
1186 -
1187 - return [
1188 - 'success' => true,
1189 - 'data' => $enhanced_data,
1190 - 'message' => 'Intelligent dashboard data retrieved successfully'
1191 - ];
1192 - }
1193 -
1194 - /**
1195 - * Get intelligent SEO opportunities with prioritization
1196 - *
1197 - * @param string $date_range Date range for analysis
1198 - * @return array Enhanced opportunities with intelligence
1199 - */
1200 - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
1201 - // Get base opportunities data
1202 - $opportunities_data = $this->get_seo_opportunities($date_range);
1203 -
1204 - // Check if there's an error in the data
1205 - if (isset($opportunities_data['error'])) {
1206 - return [
1207 - 'success' => false,
1208 - 'data' => null,
1209 - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
1210 - 'timestamp' => current_time('mysql')
1211 - ];
1212 - }
1213 -
1214 - // The opportunities payload itself has no search_performance key — that
1215 - // data lives in the (cached) dashboard payload. Pull it from there both
1216 - // for the availability check and as input for the opportunity detectors;
1217 - // checking $opportunities_data['search_performance'] here used to make
1218 - // this method always bail with "No Search Console data available".
1219 - $dashboard_data = $this->get_dashboard_data($date_range);
1220 - $search_performance = $dashboard_data['search_performance'] ?? [];
1221 - $opportunities_data['search_performance'] = $search_performance;
1222 -
1223 - $has_search_data = !empty($search_performance['rows']) ||
1224 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1225 - ($search_performance['total_impressions'] ?? 0) > 0;
1226 -
1227 - if (!$has_search_data) {
1228 - return [
1229 - 'success' => false,
1230 - 'data' => null,
1231 - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
1232 - 'timestamp' => current_time('mysql')
1233 - ];
1234 - }
1235 -
1236 - // Intelligence engine is a Pro-only feature — gate on the real Pro signal,
1237 - // not class_exists() (the classes ship in Free and would autoload).
1238 - if (!Plan_Config::is_pro()) {
1239 - return [
1240 - 'success' => false,
1241 - 'data' => null,
1242 - 'message' => 'Intelligent opportunities require ThinkRank Pro.',
1243 - 'timestamp' => current_time('mysql')
1244 - ];
1245 - }
1246 -
1247 - $opportunity_detector = new SEO_Opportunity_Detector();
1248 - $scoring_engine = new SEO_Scoring_Engine();
1249 -
1250 - $data = $opportunities_data;
1251 -
1252 - // Detect intelligent opportunities
1253 - $search_console_data = $data['search_performance'] ?? [];
1254 - $analytics_data = $data;
1255 -
1256 - $intelligent_opportunities = [
1257 - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
1258 - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
1259 - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
1260 - ];
1261 -
1262 - // Prioritize all opportunities. prioritize_opportunities() expects
1263 - // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list.
1264 - $opportunities_by_category = [
1265 - 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
1266 - 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
1267 - 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [],
1268 - ];
1269 - $all_opportunities = array_merge(...array_values($opportunities_by_category));
1270 -
1271 - $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category);
1272 - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
1273 -
1274 - // Enhance original data with intelligence
1275 - $enhanced_data = array_merge($data, [
1276 - 'intelligent_opportunities' => $intelligent_opportunities,
1277 - 'prioritized_opportunities' => $prioritized,
1278 - 'impact_effort_matrix' => $impact_matrix,
1279 - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
1280 - 'last_analyzed' => current_time('mysql')
1281 - ]);
1282 -
1283 - return [
1284 - 'success' => true,
1285 - 'data' => $enhanced_data,
1286 - 'message' => 'Intelligent SEO opportunities retrieved successfully'
1287 - ];
1288 - }
1289 -
1290 - /**
1291 - * Get SEO performance insights
1292 - *
1293 - * @param string $date_range Date range for analysis
1294 - * @return array SEO insights data
1295 - */
1296 - public function get_seo_insights(string $date_range = '30d'): array {
1297 - $cache_key = "seo_insights_{$date_range}";
1298 - $cached_data = get_transient($cache_key);
1299 -
1300 - if ($cached_data !== false) {
1301 - return [
1302 - 'success' => true,
1303 - 'data' => $cached_data,
1304 - 'cached' => true,
1305 - 'message' => 'SEO insights retrieved from cache'
1306 - ];
1307 - }
1308 -
1309 - try {
1310 - // Get dashboard data for analysis
1311 - $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
1312 -
1313 - if (!$dashboard_result['success']) {
1314 - return $dashboard_result;
1315 - }
1316 -
1317 - $dashboard_data = $dashboard_result['data'];
1318 - $intelligence = $dashboard_data['intelligence'] ?? [];
1319 -
1320 - // Insights are a Pro-only feature — gate on the real Pro signal,
1321 - // not class_exists() (SEO_Insight_Generator ships in Free too).
1322 - if (!Plan_Config::is_pro()) {
1323 - return [
1324 - 'success' => false,
1325 - 'data' => null,
1326 - 'message' => 'SEO insights require ThinkRank Pro.',
1327 - 'timestamp' => current_time('mysql')
1328 - ];
1329 - }
1330 -
1331 - $insight_generator = new SEO_Insight_Generator();
1332 -
1333 - // Collect all insights
1334 - $all_insights = [];
1335 -
1336 - if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
1337 - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
1338 - }
1339 -
1340 - if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
1341 - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
1342 - }
1343 -
1344 - if (!empty($intelligence['insights']['content_insights']['insights'])) {
1345 - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
1346 - }
1347 -
1348 - // Format and prioritize insights
1349 - $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
1350 - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
1351 -
1352 - $insights_data = [
1353 - 'insights' => $prioritized_insights['prioritized_insights'],
1354 - 'summary' => [
1355 - 'total_insights' => count($formatted_insights),
1356 - 'high_impact_count' => $prioritized_insights['high_impact_count'],
1357 - 'action_required_count' => $prioritized_insights['action_required_count']
1358 - ],
1359 - 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
1360 - 'generated_at' => current_time('mysql')
1361 - ];
1362 -
1363 - // Cache the results
1364 - set_transient($cache_key, $insights_data, $this->cache_duration);
1365 -
1366 - return [
1367 - 'success' => true,
1368 - 'data' => $insights_data,
1369 - 'cached' => false,
1370 - 'message' => 'SEO insights generated successfully'
1371 - ];
1372 - } catch (\Exception $e) {
1373 - return [
1374 - 'success' => false,
1375 - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
1376 - 'data' => null
1377 - ];
1378 - }
1379 - }
1380 -
1381 - /**
1382 - * Check if real analytics data is available
1383 - *
1384 - * @param array $dashboard_data Dashboard data to check
1385 - * @return bool True if real data is available
1386 - */
1387 - private function has_real_data(array $dashboard_data): bool {
1388 - // Check if we have meaningful traffic data
1389 - $traffic = $dashboard_data['traffic'] ?? [];
1390 - $search_performance = $dashboard_data['search_performance'] ?? [];
1391 -
1392 - $has_traffic = !empty($traffic) && (
1393 - ($traffic['sessions'] ?? 0) > 0 ||
1394 - ($traffic['pageviews'] ?? 0) > 0 ||
1395 - ($traffic['active_users'] ?? 0) > 0
1396 - );
1397 -
1398 - $has_search_data = !empty($search_performance) && (
1399 - !empty($search_performance['rows']) ||
1400 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1401 - ($search_performance['total_impressions'] ?? 0) > 0
1402 - );
1403 -
1404 - return $has_traffic || $has_search_data;
1405 - }
1406 -
1407 - /**
1408 - * Get historical data for trend comparison
1409 - *
1410 - * @param string $current_range Current date range
1411 - * @return array Historical data
1412 - */
1413 - private function get_historical_data(string $current_range): array {
1414 - // Calculate previous period based on current range
1415 - $previous_range = $this->calculate_previous_period($current_range);
1416 -
1417 - // Try to get actual historical data from previous period
1418 - $historical_data = $this->get_dashboard_data($previous_range);
1419 -
1420 - // Return the actual historical data (may be empty if no real data available)
1421 - return [
1422 - 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
1423 - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
1424 - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
1425 - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
1426 - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
1427 - ];
1428 - }
1429 -
1430 - /**
1431 - * Calculate previous period for comparison
1432 - *
1433 - * @param string $current_range Current range
1434 - * @return string Previous period range
1435 - */
1436 - private function calculate_previous_period(string $current_range): string {
1437 - // Simple mapping for now - could be enhanced with actual date calculations
1438 - $period_mapping = [
1439 - '7d' => '14d',
1440 - '30d' => '60d',
1441 - '90d' => '180d'
1442 - ];
1443 -
1444 - return $period_mapping[$current_range] ?? '60d';
1445 - }
1446 -
1447 - /**
1448 - * Generate opportunity summary
1449 - *
1450 - * @param array $opportunities All opportunities
1451 - * @return array Opportunity summary
1452 - */
1453 - private function generate_opportunity_summary(array $opportunities): array {
1454 - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
1455 - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
1456 - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
1457 -
1458 - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
1459 -
1460 - $potential_clicks = 0;
1461 - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
1462 - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
1463 - }
1464 -
1465 - return [
1466 - 'total_opportunities' => $total_opportunities,
1467 - 'quick_wins_count' => $quick_wins_count,
1468 - 'content_opportunities_count' => $content_opportunities_count,
1469 - 'keyword_opportunities_count' => $keyword_opportunities_count,
1470 - 'potential_additional_clicks' => $potential_clicks,
1471 - 'priority_recommendation' => $quick_wins_count > 0 ?
1472 - 'Focus on quick wins first for immediate impact' :
1473 - 'Focus on content optimization for long-term growth'
1474 - ];
1475 - }
1476 -
1477 - /**
1478 - * Clear intelligence cache
1479 - *
1480 - * @return array Clear result
1481 - */
1482 - public function clear_intelligence_cache(): array {
1483 - $intelligence_cache_keys = [
1484 - 'seo_insights_7d',
1485 - 'seo_insights_30d',
1486 - 'seo_insights_90d',
1487 - 'intelligent_dashboard_7d',
1488 - 'intelligent_dashboard_30d',
1489 - 'intelligent_dashboard_90d',
1490 - 'intelligent_opportunities_7d',
1491 - 'intelligent_opportunities_30d',
1492 - 'intelligent_opportunities_90d'
1493 - ];
1494 -
1495 - $cleared = 0;
1496 - foreach ($intelligence_cache_keys as $key) {
1497 - if (delete_transient($key)) {
1498 - $cleared++;
1499 - }
1500 - }
1501 -
1502 - return [
1503 - 'success' => true,
1504 - 'message' => "Cleared {$cleared} intelligence cache entries",
1505 - 'cleared_count' => $cleared,
1506 - 'timestamp' => current_time('mysql')
1507 - ];
1508 1224 }
1509 1225 }