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