| @@ -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; |
| @@ -602,66 +601,72 @@ | ||
| 602 | 601 | * |
| 603 | 602 | * @return array Connection test results |
| 604 | 603 | */ |
| 605 | 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. | |
| 606 | 608 | $results = [ |
| 607 | - 'google_analytics' => ['status' => 'not_configured'], | |
| 608 | - 'search_console' => ['status' => 'not_configured'], | |
| 609 | - 'pagespeed' => ['status' => 'not_configured'] | |
| 609 | + 'google_analytics' => ['status' => 'not_configured', 'message' => ''], | |
| 610 | + 'search_console' => ['status' => 'not_configured', 'message' => ''], | |
| 611 | + 'pagespeed' => ['status' => 'not_configured', 'message' => ''] | |
| 610 | 612 | ]; |
| 611 | 613 | |
| 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 | - ]; | |
| 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; | |
| 626 | 626 | } |
| 627 | + | |
| 628 | + $results[$service] = $this->describe_connection_test($client); | |
| 627 | 629 | } |
| 628 | 630 | |
| 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 | - ]; | |
| 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' => '']; | |
| 643 | 656 | } |
| 644 | - } | |
| 645 | 657 | |
| 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 | - } | |
| 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 | + ]; | |
| 661 | 668 | } |
| 662 | - | |
| 663 | - return $results; | |
| 664 | 669 | } |
| 665 | 670 | |
| 666 | 671 | /** |
| 667 | 672 | * Get analytics dashboard data |
| @@ -711,10 +716,8 @@ | ||
| 711 | 716 | // 401s are re-thrown so the token-refresh retry below runs. |
| 712 | 717 | if ($this->analytics_client) { |
| 713 | 718 | try { |
| 714 | 719 | $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 | 720 | } catch (\Exception $ga_error) { |
| 718 | 721 | if ($ga_error->getCode() === 401) { |
| 719 | 722 | throw $ga_error; |
| 720 | 723 | } |
| @@ -747,35 +750,21 @@ | ||
| 747 | 750 | // Fallback to old client if new one fails init (shouldn't happen if they use same creds) |
| 748 | 751 | $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000); |
| 749 | 752 | } |
| 750 | 753 | |
| 751 | - // Calculate position distribution | |
| 752 | - $position_distribution = [ | |
| 753 | - 'top_3' => 0, | |
| 754 | - '4_10' => 0, | |
| 755 | - '10_50' => 0, | |
| 756 | - '51_100' => 0 | |
| 757 | - ]; | |
| 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 | + ); | |
| 758 | 762 | |
| 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 | 763 | $dashboard_data['search_performance'] = array_merge($search_performance, [ |
| 773 | 764 | 'totals' => $totals, |
| 774 | 765 | 'position_distribution' => $position_distribution |
| 775 | 766 | ]); |
| 776 | - | |
| 777 | - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10); | |
| 778 | 767 | } // Closing Search Console block |
| 779 | 768 | |
| 780 | 769 | // If successful, break loop |
| 781 | 770 | break; |
| @@ -813,8 +802,132 @@ | ||
| 813 | 802 | return $dashboard_data; |
| 814 | 803 | } |
| 815 | 804 | |
| 816 | 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; | |
| 927 | + } | |
| 928 | + | |
| 929 | + /** | |
| 817 | 930 | * Get Core Web Vitals for the analytics dashboard, cached independently |
| 818 | 931 | * of the dashboard payload. |
| 819 | 932 | * |
| 820 | 933 | * Successful results are cached for 1 hour; failures are never cached |
| @@ -973,44 +1086,8 @@ | ||
| 973 | 1086 | } |
| 974 | 1087 | } |
| 975 | 1088 | |
| 976 | 1089 | /** |
| 977 | - * Get site indexing status | |
| 978 | - * | |
| 979 | - * @return array Indexing status data | |
| 980 | - */ | |
| 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; | |
| 987 | - } | |
| 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(); | |
| 1001 | - } | |
| 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; | |
| 1010 | - } | |
| 1011 | - | |
| 1012 | - /** | |
| 1013 | 1090 | * Force refresh of all cached data |
| 1014 | 1091 | * |
| 1015 | 1092 | * @return array Refresh results |
| 1016 | 1093 | */ |
| @@ -1027,11 +1104,8 @@ | ||
| 1027 | 1104 | 'analytics_dashboard_v5_180d', |
| 1028 | 1105 | 'seo_opportunities_7d', |
| 1029 | 1106 | 'seo_opportunities_30d', |
| 1030 | 1107 | 'seo_opportunities_90d', |
| 1031 | - 'seo_insights_7d', | |
| 1032 | - 'seo_insights_30d', | |
| 1033 | - 'seo_insights_90d', | |
| 1034 | 1108 | 'indexing_status', |
| 1035 | 1109 | 'thinkrank_dashboard_cwv' |
| 1036 | 1110 | ]; |
| 1037 | 1111 | |
| @@ -1099,411 +1173,6 @@ | ||
| 1099 | 1173 | */ |
| 1100 | 1174 | public function cleanup_cache(): void { |
| 1101 | 1175 | // WordPress handles transient cleanup automatically |
| 1102 | 1176 | // 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 | 1177 | } |
| 1509 | 1178 | } |