| @@ -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; |
| @@ -88,8 +87,45 @@ | ||
| 88 | 87 | */ |
| 89 | 88 | private static bool $token_refreshed_this_request = false; |
| 90 | 89 | |
| 91 | 90 | /** |
| 91 | + * Single-flight lock for the OAuth refresh exchange. | |
| 92 | + * | |
| 93 | + * @var string | |
| 94 | + */ | |
| 95 | + private const REFRESH_LOCK = 'thinkrank_token_refresh_lock'; | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * How long a held refresh lock stays valid. Longer than the request | |
| 99 | + * timeout below, so a request that dies mid-exchange still frees it. | |
| 100 | + * | |
| 101 | + * @var int | |
| 102 | + */ | |
| 103 | + private const REFRESH_LOCK_TTL = 60; | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * Set after a failed exchange; suppresses retries until it expires. | |
| 107 | + * | |
| 108 | + * @var string | |
| 109 | + */ | |
| 110 | + private const REFRESH_BACKOFF = 'thinkrank_token_refresh_backoff'; | |
| 111 | + | |
| 112 | + /** | |
| 113 | + * How long to stay quiet after a failed exchange. | |
| 114 | + * | |
| 115 | + * @var int | |
| 116 | + */ | |
| 117 | + private const REFRESH_BACKOFF_TTL = 300; | |
| 118 | + | |
| 119 | + /** | |
| 120 | + * Timeout for the refresh exchange. A healthy proxy answers in ~1s; the | |
| 121 | + * old 30s meant one outage held a request open for half a minute. | |
| 122 | + * | |
| 123 | + * @var int | |
| 124 | + */ | |
| 125 | + private const REFRESH_TIMEOUT = 10; | |
| 126 | + | |
| 127 | + /** | |
| 92 | 128 | * Constructor |
| 93 | 129 | * |
| 94 | 130 | * @param Settings_Manager|null $settings_manager Settings manager instance |
| 95 | 131 | */ |
| @@ -108,10 +144,11 @@ | ||
| 108 | 144 | public function init(): void { |
| 109 | 145 | // Register custom cron interval (45 minutes) |
| 110 | 146 | add_filter('cron_schedules', [$this, 'add_cron_intervals']); |
| 111 | 147 | |
| 112 | - // Initialize Google API clients | |
| 113 | - add_action('init', [$this, 'initialize_clients']); | |
| 148 | + // Initialize Google API clients — but only in the contexts that can use | |
| 149 | + // them. See maybe_initialize_clients(). | |
| 150 | + add_action('init', [$this, 'maybe_initialize_clients']); | |
| 114 | 151 | |
| 115 | 152 | // Initialize token refresh scheduling |
| 116 | 153 | add_action('init', [$this, 'init_token_refresh']); |
| 117 | 154 | |
| @@ -131,11 +168,16 @@ | ||
| 131 | 168 | * @param array $schedules Existing cron schedules |
| 132 | 169 | * @return array Modified cron schedules |
| 133 | 170 | */ |
| 134 | 171 | public function add_cron_intervals(array $schedules): array { |
| 172 | + // Only translate once `init` has run: wp_get_schedules() can be reached | |
| 173 | + // before then (wp_schedule_event() at plugin boot does), and translating | |
| 174 | + // that early trips the _load_textdomain_just_in_time notice on WP 6.7+. | |
| 135 | 175 | $schedules['thinkrank_45min'] = [ |
| 136 | 176 | 'interval' => 2700, // 45 minutes in seconds |
| 137 | - 'display' => __('Every 45 Minutes', 'thinkrank') | |
| 177 | + 'display' => did_action('init') | |
| 178 | + ? __('Every 45 Minutes', 'thinkrank') | |
| 179 | + : 'Every 45 Minutes' | |
| 138 | 180 | ]; |
| 139 | 181 | return $schedules; |
| 140 | 182 | } |
| 141 | 183 | |
| @@ -172,8 +214,43 @@ | ||
| 172 | 214 | return $this->get_setting('search_console_property', get_site_url()); |
| 173 | 215 | } |
| 174 | 216 | |
| 175 | 217 | /** |
| 218 | + * Initialize the Google clients on `init`, in the contexts that use them. | |
| 219 | + * | |
| 220 | + * initialize_clients() refreshes the OAuth token, which is a blocking | |
| 221 | + * outbound POST to the OAuth proxy. Hooked unconditionally it ran on every | |
| 222 | + * anonymous front-end request, so a proxy outage became a site-wide TTFB | |
| 223 | + * collapse — with each visitor waiting for the network call, and none of | |
| 224 | + * them able to use a Google client anyway. No front-end code path reads | |
| 225 | + * one: every consumer is a REST endpoint, a cron callback or WP-CLI, and | |
| 226 | + * each either calls initialize_clients() itself or goes through | |
| 227 | + * get_search_console_client(), which initializes lazily (#383). | |
| 228 | + * | |
| 229 | + * @since 2.0.1 | |
| 230 | + * @return void | |
| 231 | + */ | |
| 232 | + public function maybe_initialize_clients(): void { | |
| 233 | + $wanted = is_admin() | |
| 234 | + || wp_doing_cron() | |
| 235 | + || (defined('REST_REQUEST') && REST_REQUEST) | |
| 236 | + || (defined('WP_CLI') && WP_CLI); | |
| 237 | + | |
| 238 | + /** | |
| 239 | + * Filter whether the Google API clients are initialized for this request. | |
| 240 | + * | |
| 241 | + * @since 2.0.1 | |
| 242 | + * | |
| 243 | + * @param bool $wanted Whether to initialize the clients. | |
| 244 | + */ | |
| 245 | + if (!apply_filters('thinkrank_initialize_google_clients', $wanted)) { | |
| 246 | + return; | |
| 247 | + } | |
| 248 | + | |
| 249 | + $this->initialize_clients(); | |
| 250 | + } | |
| 251 | + | |
| 252 | + /** | |
| 176 | 253 | * Initialize Google API clients |
| 177 | 254 | * Following AI_Manager client initialization pattern |
| 178 | 255 | * |
| 179 | 256 | * @return void |
| @@ -370,10 +447,30 @@ | ||
| 370 | 447 | // Calculate absolute expiration time (created + relative seconds) |
| 371 | 448 | $expiration_time = $created + $expires_in; |
| 372 | 449 | |
| 373 | 450 | // Refresh if forced, expired, or expiring within 5 minutes (300 seconds) |
| 374 | - if ($force || $current_time >= ($expiration_time - 300)) { | |
| 451 | + if (!$force && $current_time < ($expiration_time - 300)) { | |
| 452 | + return; | |
| 453 | + } | |
| 375 | 454 | |
| 455 | + // A failed exchange leaves google_token_created untouched, so the | |
| 456 | + // expiry condition above stays true and the next request tries again. | |
| 457 | + // Without a backoff a proxy outage means one blocking network call per | |
| 458 | + // request, forever. A forced refresh — the user reconnecting — is a | |
| 459 | + // deliberate act and skips the wait (#383). | |
| 460 | + if (!$force && get_transient(self::REFRESH_BACKOFF)) { | |
| 461 | + return; | |
| 462 | + } | |
| 463 | + | |
| 464 | + // One exchange at a time. Concurrent callers past the expiry threshold | |
| 465 | + // would otherwise all refresh at once and invalidate each other's | |
| 466 | + // in-flight grants; the losers fall through with the current token and | |
| 467 | + // pick up the new one on their next read. | |
| 468 | + if (!$force && !$this->acquire_refresh_lock()) { | |
| 469 | + return; | |
| 470 | + } | |
| 471 | + | |
| 472 | + try { | |
| 376 | 473 | // The proxy owns the Google app credentials; we only ever hand it |
| 377 | 474 | // the refresh token and let it perform the exchange. |
| 378 | 475 | $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [ |
| 379 | 476 | 'headers' => [ |
| @@ -384,12 +481,13 @@ | ||
| 384 | 481 | 'action' => 'refresh', |
| 385 | 482 | 'refresh_token' => $refresh_token, |
| 386 | 483 | 'site' => home_url(), |
| 387 | 484 | ]), |
| 388 | - 'timeout' => 30 | |
| 485 | + 'timeout' => self::REFRESH_TIMEOUT | |
| 389 | 486 | ]); |
| 390 | 487 | |
| 391 | 488 | if (is_wp_error($response)) { |
| 489 | + $this->back_off_refresh(); | |
| 392 | 490 | return; |
| 393 | 491 | } |
| 394 | 492 | |
| 395 | 493 | $body = wp_remote_retrieve_body($response); |
| @@ -402,12 +500,15 @@ | ||
| 402 | 500 | // the site is connected — otherwise the UI shows "Connected" |
| 403 | 501 | // while every API call 401s. |
| 404 | 502 | if (($data['error'] ?? '') === 'invalid_grant') { |
| 405 | 503 | Google_OAuth_Proxy::mark_revoked(); |
| 504 | + return; | |
| 406 | 505 | } |
| 407 | 506 | |
| 408 | 507 | // Any other failure (network blip, proxy 502) is transient; |
| 409 | - // leave the credentials alone and let the next run retry. | |
| 508 | + // leave the credentials alone and let the next run retry — | |
| 509 | + // after the backoff, not on the very next request. | |
| 510 | + $this->back_off_refresh(); | |
| 410 | 511 | return; |
| 411 | 512 | } |
| 412 | 513 | |
| 413 | 514 | // Update settings with new token data |
| @@ -423,15 +524,79 @@ | ||
| 423 | 524 | 'google_refresh_token' => $data['refresh_token'] |
| 424 | 525 | ], 'integrations'); |
| 425 | 526 | } |
| 426 | 527 | |
| 528 | + // A success clears any backoff a previous failure left behind. | |
| 529 | + delete_transient(self::REFRESH_BACKOFF); | |
| 530 | + | |
| 427 | 531 | // Drop the memoized settings merge so subsequent reads (e.g. |
| 428 | 532 | // re-initializing clients) see the fresh token. |
| 429 | 533 | $this->merged_settings = null; |
| 534 | + } finally { | |
| 535 | + $this->release_refresh_lock(); | |
| 430 | 536 | } |
| 431 | 537 | } |
| 432 | 538 | |
| 433 | 539 | /** |
| 540 | + * Take the single-flight lock for the refresh exchange. | |
| 541 | + * | |
| 542 | + * @since 2.0.1 | |
| 543 | + * @return bool True when this request holds the lock. | |
| 544 | + */ | |
| 545 | + private function acquire_refresh_lock(): bool { | |
| 546 | + // With a persistent object cache, add is atomic — memcached and Redis | |
| 547 | + // both fail an ADD on an existing key — so exactly one caller wins. | |
| 548 | + if (wp_using_ext_object_cache()) { | |
| 549 | + return (bool) wp_cache_add(self::REFRESH_LOCK, time(), 'thinkrank', self::REFRESH_LOCK_TTL); | |
| 550 | + } | |
| 551 | + | |
| 552 | + // Without one, the options table is the shared store, and the unique | |
| 553 | + // index on option_name gives add_option() the same all-or-nothing | |
| 554 | + // result. set_transient() would not: it is an update, so every | |
| 555 | + // concurrent caller would "win". | |
| 556 | + if (add_option(self::REFRESH_LOCK, time(), '', 'no')) { | |
| 557 | + return true; | |
| 558 | + } | |
| 559 | + | |
| 560 | + // Reclaim a lock whose holder died before releasing it. | |
| 561 | + $held = (int) get_option(self::REFRESH_LOCK); | |
| 562 | + | |
| 563 | + if ($held > 0 && (time() - $held) > self::REFRESH_LOCK_TTL) { | |
| 564 | + delete_option(self::REFRESH_LOCK); | |
| 565 | + | |
| 566 | + return (bool) add_option(self::REFRESH_LOCK, time(), '', 'no'); | |
| 567 | + } | |
| 568 | + | |
| 569 | + return false; | |
| 570 | + } | |
| 571 | + | |
| 572 | + /** | |
| 573 | + * Release the single-flight lock. | |
| 574 | + * | |
| 575 | + * @since 2.0.1 | |
| 576 | + * @return void | |
| 577 | + */ | |
| 578 | + private function release_refresh_lock(): void { | |
| 579 | + if (wp_using_ext_object_cache()) { | |
| 580 | + wp_cache_delete(self::REFRESH_LOCK, 'thinkrank'); | |
| 581 | + | |
| 582 | + return; | |
| 583 | + } | |
| 584 | + | |
| 585 | + delete_option(self::REFRESH_LOCK); | |
| 586 | + } | |
| 587 | + | |
| 588 | + /** | |
| 589 | + * Stop retrying the exchange for a while after a failure. | |
| 590 | + * | |
| 591 | + * @since 2.0.1 | |
| 592 | + * @return void | |
| 593 | + */ | |
| 594 | + private function back_off_refresh(): void { | |
| 595 | + set_transient(self::REFRESH_BACKOFF, time(), self::REFRESH_BACKOFF_TTL); | |
| 596 | + } | |
| 597 | + | |
| 598 | + /** | |
| 434 | 599 | * Test all Google API connections |
| 435 | 600 | * Following ThinkRank test_connection patterns |
| 436 | 601 | * |
| 437 | 602 | * @return array Connection test results |
| @@ -545,10 +710,8 @@ | ||
| 545 | 710 | // 401s are re-thrown so the token-refresh retry below runs. |
| 546 | 711 | if ($this->analytics_client) { |
| 547 | 712 | try { |
| 548 | 713 | $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range); |
| 549 | - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range); | |
| 550 | - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range); | |
| 551 | 714 | } catch (\Exception $ga_error) { |
| 552 | 715 | if ($ga_error->getCode() === 401) { |
| 553 | 716 | throw $ga_error; |
| 554 | 717 | } |
| @@ -606,10 +769,8 @@ | ||
| 606 | 769 | $dashboard_data['search_performance'] = array_merge($search_performance, [ |
| 607 | 770 | 'totals' => $totals, |
| 608 | 771 | 'position_distribution' => $position_distribution |
| 609 | 772 | ]); |
| 610 | - | |
| 611 | - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10); | |
| 612 | 773 | } // Closing Search Console block |
| 613 | 774 | |
| 614 | 775 | // If successful, break loop |
| 615 | 776 | break; |
| @@ -807,44 +968,8 @@ | ||
| 807 | 968 | } |
| 808 | 969 | } |
| 809 | 970 | |
| 810 | 971 | /** |
| 811 | - * Get site indexing status | |
| 812 | - * | |
| 813 | - * @return array Indexing status data | |
| 814 | - */ | |
| 815 | - public function get_indexing_status(): array { | |
| 816 | - $cache_key = 'indexing_status'; | |
| 817 | - $cached_data = get_transient($cache_key); | |
| 818 | - | |
| 819 | - if ($cached_data !== false) { | |
| 820 | - return $cached_data; | |
| 821 | - } | |
| 822 | - | |
| 823 | - $indexing_data = [ | |
| 824 | - 'status' => 'unknown', | |
| 825 | - 'last_updated' => current_time('mysql') | |
| 826 | - ]; | |
| 827 | - | |
| 828 | - try { | |
| 829 | - if ($this->search_console_client) { | |
| 830 | - $site_url = $this->get_setting('search_console_property', get_site_url()); | |
| 831 | - $indexing_data = $this->search_console_client->get_indexing_status($site_url); | |
| 832 | - } | |
| 833 | - } catch (\Exception $e) { | |
| 834 | - $indexing_data['error'] = $e->getMessage(); | |
| 835 | - } | |
| 836 | - | |
| 837 | - // Cache successful results for 1 hour. Errors are never cached, so a | |
| 838 | - // transient Google failure isn't served as "no data" for a full hour. | |
| 839 | - if (empty($indexing_data['error'])) { | |
| 840 | - set_transient($cache_key, $indexing_data, 3600); | |
| 841 | - } | |
| 842 | - | |
| 843 | - return $indexing_data; | |
| 844 | - } | |
| 845 | - | |
| 846 | - /** | |
| 847 | 972 | * Force refresh of all cached data |
| 848 | 973 | * |
| 849 | 974 | * @return array Refresh results |
| 850 | 975 | */ |
| @@ -861,11 +986,8 @@ | ||
| 861 | 986 | 'analytics_dashboard_v5_180d', |
| 862 | 987 | 'seo_opportunities_7d', |
| 863 | 988 | 'seo_opportunities_30d', |
| 864 | 989 | 'seo_opportunities_90d', |
| 865 | - 'seo_insights_7d', | |
| 866 | - 'seo_insights_30d', | |
| 867 | - 'seo_insights_90d', | |
| 868 | 990 | 'indexing_status', |
| 869 | 991 | 'thinkrank_dashboard_cwv' |
| 870 | 992 | ]; |
| 871 | 993 | |
| @@ -933,411 +1055,6 @@ | ||
| 933 | 1055 | */ |
| 934 | 1056 | public function cleanup_cache(): void { |
| 935 | 1057 | // WordPress handles transient cleanup automatically |
| 936 | 1058 | // This method is for future custom cache cleanup if needed |
| 937 | - } | |
| 938 | - | |
| 939 | - // ======================================== | |
| 940 | - // SEO Intelligence Enhancement Methods | |
| 941 | - // ======================================== | |
| 942 | - | |
| 943 | - /** | |
| 944 | - * Get intelligent dashboard data with trends and insights | |
| 945 | - * | |
| 946 | - * @param string $date_range Date range for analysis | |
| 947 | - * @return array Enhanced dashboard data with intelligence | |
| 948 | - */ | |
| 949 | - public function get_intelligent_dashboard_data(string $date_range = '30d'): array { | |
| 950 | - // Get base dashboard data | |
| 951 | - $dashboard_data = $this->get_dashboard_data($date_range); | |
| 952 | - | |
| 953 | - // Check if there's an error in the data | |
| 954 | - if (isset($dashboard_data['error'])) { | |
| 955 | - return [ | |
| 956 | - 'success' => false, | |
| 957 | - 'data' => null, | |
| 958 | - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'], | |
| 959 | - 'timestamp' => current_time('mysql') | |
| 960 | - ]; | |
| 961 | - } | |
| 962 | - | |
| 963 | - // Check if we have real data available | |
| 964 | - if (!$this->has_real_data($dashboard_data)) { | |
| 965 | - return [ | |
| 966 | - 'success' => false, | |
| 967 | - 'data' => null, | |
| 968 | - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.', | |
| 969 | - 'timestamp' => current_time('mysql') | |
| 970 | - ]; | |
| 971 | - } | |
| 972 | - | |
| 973 | - // Intelligence engine is a Pro-only feature. The four SEO_* classes ship | |
| 974 | - // in Free too (the PSR-4 autoloader would resolve them), so class_exists() | |
| 975 | - // can't gate this — check the real Pro signal instead. | |
| 976 | - if (!Plan_Config::is_pro()) { | |
| 977 | - return [ | |
| 978 | - 'success' => false, | |
| 979 | - 'data' => null, | |
| 980 | - 'message' => 'Intelligent dashboard requires ThinkRank Pro.', | |
| 981 | - 'timestamp' => current_time('mysql') | |
| 982 | - ]; | |
| 983 | - } | |
| 984 | - | |
| 985 | - $trend_analyzer = new SEO_Trend_Analyzer(); | |
| 986 | - $scoring_engine = new SEO_Scoring_Engine(); | |
| 987 | - $insight_generator = new SEO_Insight_Generator(); | |
| 988 | - | |
| 989 | - $data = $dashboard_data; | |
| 990 | - | |
| 991 | - // Generate trend analysis | |
| 992 | - $current_data = $data; | |
| 993 | - $historical_data = $this->get_historical_data($date_range); | |
| 994 | - | |
| 995 | - $trends = [ | |
| 996 | - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data), | |
| 997 | - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range), | |
| 998 | - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? []) | |
| 999 | - ]; | |
| 1000 | - | |
| 1001 | - // Calculate SEO health score | |
| 1002 | - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []); | |
| 1003 | - | |
| 1004 | - // Generate insights | |
| 1005 | - $insights = [ | |
| 1006 | - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']), | |
| 1007 | - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']), | |
| 1008 | - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends']) | |
| 1009 | - ]; | |
| 1010 | - | |
| 1011 | - // Combine all intelligence data | |
| 1012 | - $enhanced_data = array_merge($data, [ | |
| 1013 | - 'intelligence' => [ | |
| 1014 | - 'trends' => $trends, | |
| 1015 | - 'seo_health_score' => $seo_health, | |
| 1016 | - 'insights' => $insights, | |
| 1017 | - 'last_analyzed' => current_time('mysql') | |
| 1018 | - ] | |
| 1019 | - ]); | |
| 1020 | - | |
| 1021 | - return [ | |
| 1022 | - 'success' => true, | |
| 1023 | - 'data' => $enhanced_data, | |
| 1024 | - 'message' => 'Intelligent dashboard data retrieved successfully' | |
| 1025 | - ]; | |
| 1026 | - } | |
| 1027 | - | |
| 1028 | - /** | |
| 1029 | - * Get intelligent SEO opportunities with prioritization | |
| 1030 | - * | |
| 1031 | - * @param string $date_range Date range for analysis | |
| 1032 | - * @return array Enhanced opportunities with intelligence | |
| 1033 | - */ | |
| 1034 | - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array { | |
| 1035 | - // Get base opportunities data | |
| 1036 | - $opportunities_data = $this->get_seo_opportunities($date_range); | |
| 1037 | - | |
| 1038 | - // Check if there's an error in the data | |
| 1039 | - if (isset($opportunities_data['error'])) { | |
| 1040 | - return [ | |
| 1041 | - 'success' => false, | |
| 1042 | - 'data' => null, | |
| 1043 | - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'], | |
| 1044 | - 'timestamp' => current_time('mysql') | |
| 1045 | - ]; | |
| 1046 | - } | |
| 1047 | - | |
| 1048 | - // The opportunities payload itself has no search_performance key — that | |
| 1049 | - // data lives in the (cached) dashboard payload. Pull it from there both | |
| 1050 | - // for the availability check and as input for the opportunity detectors; | |
| 1051 | - // checking $opportunities_data['search_performance'] here used to make | |
| 1052 | - // this method always bail with "No Search Console data available". | |
| 1053 | - $dashboard_data = $this->get_dashboard_data($date_range); | |
| 1054 | - $search_performance = $dashboard_data['search_performance'] ?? []; | |
| 1055 | - $opportunities_data['search_performance'] = $search_performance; | |
| 1056 | - | |
| 1057 | - $has_search_data = !empty($search_performance['rows']) || | |
| 1058 | - ($search_performance['total_clicks'] ?? 0) > 0 || | |
| 1059 | - ($search_performance['total_impressions'] ?? 0) > 0; | |
| 1060 | - | |
| 1061 | - if (!$has_search_data) { | |
| 1062 | - return [ | |
| 1063 | - 'success' => false, | |
| 1064 | - 'data' => null, | |
| 1065 | - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.', | |
| 1066 | - 'timestamp' => current_time('mysql') | |
| 1067 | - ]; | |
| 1068 | - } | |
| 1069 | - | |
| 1070 | - // Intelligence engine is a Pro-only feature — gate on the real Pro signal, | |
| 1071 | - // not class_exists() (the classes ship in Free and would autoload). | |
| 1072 | - if (!Plan_Config::is_pro()) { | |
| 1073 | - return [ | |
| 1074 | - 'success' => false, | |
| 1075 | - 'data' => null, | |
| 1076 | - 'message' => 'Intelligent opportunities require ThinkRank Pro.', | |
| 1077 | - 'timestamp' => current_time('mysql') | |
| 1078 | - ]; | |
| 1079 | - } | |
| 1080 | - | |
| 1081 | - $opportunity_detector = new SEO_Opportunity_Detector(); | |
| 1082 | - $scoring_engine = new SEO_Scoring_Engine(); | |
| 1083 | - | |
| 1084 | - $data = $opportunities_data; | |
| 1085 | - | |
| 1086 | - // Detect intelligent opportunities | |
| 1087 | - $search_console_data = $data['search_performance'] ?? []; | |
| 1088 | - $analytics_data = $data; | |
| 1089 | - | |
| 1090 | - $intelligent_opportunities = [ | |
| 1091 | - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data), | |
| 1092 | - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data), | |
| 1093 | - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data) | |
| 1094 | - ]; | |
| 1095 | - | |
| 1096 | - // Prioritize all opportunities. prioritize_opportunities() expects | |
| 1097 | - // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list. | |
| 1098 | - $opportunities_by_category = [ | |
| 1099 | - 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [], | |
| 1100 | - 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [], | |
| 1101 | - 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [], | |
| 1102 | - ]; | |
| 1103 | - $all_opportunities = array_merge(...array_values($opportunities_by_category)); | |
| 1104 | - | |
| 1105 | - $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category); | |
| 1106 | - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities); | |
| 1107 | - | |
| 1108 | - // Enhance original data with intelligence | |
| 1109 | - $enhanced_data = array_merge($data, [ | |
| 1110 | - 'intelligent_opportunities' => $intelligent_opportunities, | |
| 1111 | - 'prioritized_opportunities' => $prioritized, | |
| 1112 | - 'impact_effort_matrix' => $impact_matrix, | |
| 1113 | - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities), | |
| 1114 | - 'last_analyzed' => current_time('mysql') | |
| 1115 | - ]); | |
| 1116 | - | |
| 1117 | - return [ | |
| 1118 | - 'success' => true, | |
| 1119 | - 'data' => $enhanced_data, | |
| 1120 | - 'message' => 'Intelligent SEO opportunities retrieved successfully' | |
| 1121 | - ]; | |
| 1122 | - } | |
| 1123 | - | |
| 1124 | - /** | |
| 1125 | - * Get SEO performance insights | |
| 1126 | - * | |
| 1127 | - * @param string $date_range Date range for analysis | |
| 1128 | - * @return array SEO insights data | |
| 1129 | - */ | |
| 1130 | - public function get_seo_insights(string $date_range = '30d'): array { | |
| 1131 | - $cache_key = "seo_insights_{$date_range}"; | |
| 1132 | - $cached_data = get_transient($cache_key); | |
| 1133 | - | |
| 1134 | - if ($cached_data !== false) { | |
| 1135 | - return [ | |
| 1136 | - 'success' => true, | |
| 1137 | - 'data' => $cached_data, | |
| 1138 | - 'cached' => true, | |
| 1139 | - 'message' => 'SEO insights retrieved from cache' | |
| 1140 | - ]; | |
| 1141 | - } | |
| 1142 | - | |
| 1143 | - try { | |
| 1144 | - // Get dashboard data for analysis | |
| 1145 | - $dashboard_result = $this->get_intelligent_dashboard_data($date_range); | |
| 1146 | - | |
| 1147 | - if (!$dashboard_result['success']) { | |
| 1148 | - return $dashboard_result; | |
| 1149 | - } | |
| 1150 | - | |
| 1151 | - $dashboard_data = $dashboard_result['data']; | |
| 1152 | - $intelligence = $dashboard_data['intelligence'] ?? []; | |
| 1153 | - | |
| 1154 | - // Insights are a Pro-only feature — gate on the real Pro signal, | |
| 1155 | - // not class_exists() (SEO_Insight_Generator ships in Free too). | |
| 1156 | - if (!Plan_Config::is_pro()) { | |
| 1157 | - return [ | |
| 1158 | - 'success' => false, | |
| 1159 | - 'data' => null, | |
| 1160 | - 'message' => 'SEO insights require ThinkRank Pro.', | |
| 1161 | - 'timestamp' => current_time('mysql') | |
| 1162 | - ]; | |
| 1163 | - } | |
| 1164 | - | |
| 1165 | - $insight_generator = new SEO_Insight_Generator(); | |
| 1166 | - | |
| 1167 | - // Collect all insights | |
| 1168 | - $all_insights = []; | |
| 1169 | - | |
| 1170 | - if (!empty($intelligence['insights']['traffic_insights']['insights'])) { | |
| 1171 | - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']); | |
| 1172 | - } | |
| 1173 | - | |
| 1174 | - if (!empty($intelligence['insights']['keyword_insights']['insights'])) { | |
| 1175 | - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']); | |
| 1176 | - } | |
| 1177 | - | |
| 1178 | - if (!empty($intelligence['insights']['content_insights']['insights'])) { | |
| 1179 | - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']); | |
| 1180 | - } | |
| 1181 | - | |
| 1182 | - // Format and prioritize insights | |
| 1183 | - $formatted_insights = $insight_generator->format_insights_for_display($all_insights); | |
| 1184 | - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights); | |
| 1185 | - | |
| 1186 | - $insights_data = [ | |
| 1187 | - 'insights' => $prioritized_insights['prioritized_insights'], | |
| 1188 | - 'summary' => [ | |
| 1189 | - 'total_insights' => count($formatted_insights), | |
| 1190 | - 'high_impact_count' => $prioritized_insights['high_impact_count'], | |
| 1191 | - 'action_required_count' => $prioritized_insights['action_required_count'] | |
| 1192 | - ], | |
| 1193 | - 'seo_health_score' => $intelligence['seo_health_score'] ?? null, | |
| 1194 | - 'generated_at' => current_time('mysql') | |
| 1195 | - ]; | |
| 1196 | - | |
| 1197 | - // Cache the results | |
| 1198 | - set_transient($cache_key, $insights_data, $this->cache_duration); | |
| 1199 | - | |
| 1200 | - return [ | |
| 1201 | - 'success' => true, | |
| 1202 | - 'data' => $insights_data, | |
| 1203 | - 'cached' => false, | |
| 1204 | - 'message' => 'SEO insights generated successfully' | |
| 1205 | - ]; | |
| 1206 | - } catch (\Exception $e) { | |
| 1207 | - return [ | |
| 1208 | - 'success' => false, | |
| 1209 | - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(), | |
| 1210 | - 'data' => null | |
| 1211 | - ]; | |
| 1212 | - } | |
| 1213 | - } | |
| 1214 | - | |
| 1215 | - /** | |
| 1216 | - * Check if real analytics data is available | |
| 1217 | - * | |
| 1218 | - * @param array $dashboard_data Dashboard data to check | |
| 1219 | - * @return bool True if real data is available | |
| 1220 | - */ | |
| 1221 | - private function has_real_data(array $dashboard_data): bool { | |
| 1222 | - // Check if we have meaningful traffic data | |
| 1223 | - $traffic = $dashboard_data['traffic'] ?? []; | |
| 1224 | - $search_performance = $dashboard_data['search_performance'] ?? []; | |
| 1225 | - | |
| 1226 | - $has_traffic = !empty($traffic) && ( | |
| 1227 | - ($traffic['sessions'] ?? 0) > 0 || | |
| 1228 | - ($traffic['pageviews'] ?? 0) > 0 || | |
| 1229 | - ($traffic['active_users'] ?? 0) > 0 | |
| 1230 | - ); | |
| 1231 | - | |
| 1232 | - $has_search_data = !empty($search_performance) && ( | |
| 1233 | - !empty($search_performance['rows']) || | |
| 1234 | - ($search_performance['total_clicks'] ?? 0) > 0 || | |
| 1235 | - ($search_performance['total_impressions'] ?? 0) > 0 | |
| 1236 | - ); | |
| 1237 | - | |
| 1238 | - return $has_traffic || $has_search_data; | |
| 1239 | - } | |
| 1240 | - | |
| 1241 | - /** | |
| 1242 | - * Get historical data for trend comparison | |
| 1243 | - * | |
| 1244 | - * @param string $current_range Current date range | |
| 1245 | - * @return array Historical data | |
| 1246 | - */ | |
| 1247 | - private function get_historical_data(string $current_range): array { | |
| 1248 | - // Calculate previous period based on current range | |
| 1249 | - $previous_range = $this->calculate_previous_period($current_range); | |
| 1250 | - | |
| 1251 | - // Try to get actual historical data from previous period | |
| 1252 | - $historical_data = $this->get_dashboard_data($previous_range); | |
| 1253 | - | |
| 1254 | - // Return the actual historical data (may be empty if no real data available) | |
| 1255 | - return [ | |
| 1256 | - 'sessions' => $historical_data['traffic']['sessions'] ?? 0, | |
| 1257 | - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0, | |
| 1258 | - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]], | |
| 1259 | - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0, | |
| 1260 | - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0 | |
| 1261 | - ]; | |
| 1262 | - } | |
| 1263 | - | |
| 1264 | - /** | |
| 1265 | - * Calculate previous period for comparison | |
| 1266 | - * | |
| 1267 | - * @param string $current_range Current range | |
| 1268 | - * @return string Previous period range | |
| 1269 | - */ | |
| 1270 | - private function calculate_previous_period(string $current_range): string { | |
| 1271 | - // Simple mapping for now - could be enhanced with actual date calculations | |
| 1272 | - $period_mapping = [ | |
| 1273 | - '7d' => '14d', | |
| 1274 | - '30d' => '60d', | |
| 1275 | - '90d' => '180d' | |
| 1276 | - ]; | |
| 1277 | - | |
| 1278 | - return $period_mapping[$current_range] ?? '60d'; | |
| 1279 | - } | |
| 1280 | - | |
| 1281 | - /** | |
| 1282 | - * Generate opportunity summary | |
| 1283 | - * | |
| 1284 | - * @param array $opportunities All opportunities | |
| 1285 | - * @return array Opportunity summary | |
| 1286 | - */ | |
| 1287 | - private function generate_opportunity_summary(array $opportunities): array { | |
| 1288 | - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []); | |
| 1289 | - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []); | |
| 1290 | - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []); | |
| 1291 | - | |
| 1292 | - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count; | |
| 1293 | - | |
| 1294 | - $potential_clicks = 0; | |
| 1295 | - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) { | |
| 1296 | - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks']; | |
| 1297 | - } | |
| 1298 | - | |
| 1299 | - return [ | |
| 1300 | - 'total_opportunities' => $total_opportunities, | |
| 1301 | - 'quick_wins_count' => $quick_wins_count, | |
| 1302 | - 'content_opportunities_count' => $content_opportunities_count, | |
| 1303 | - 'keyword_opportunities_count' => $keyword_opportunities_count, | |
| 1304 | - 'potential_additional_clicks' => $potential_clicks, | |
| 1305 | - 'priority_recommendation' => $quick_wins_count > 0 ? | |
| 1306 | - 'Focus on quick wins first for immediate impact' : | |
| 1307 | - 'Focus on content optimization for long-term growth' | |
| 1308 | - ]; | |
| 1309 | - } | |
| 1310 | - | |
| 1311 | - /** | |
| 1312 | - * Clear intelligence cache | |
| 1313 | - * | |
| 1314 | - * @return array Clear result | |
| 1315 | - */ | |
| 1316 | - public function clear_intelligence_cache(): array { | |
| 1317 | - $intelligence_cache_keys = [ | |
| 1318 | - 'seo_insights_7d', | |
| 1319 | - 'seo_insights_30d', | |
| 1320 | - 'seo_insights_90d', | |
| 1321 | - 'intelligent_dashboard_7d', | |
| 1322 | - 'intelligent_dashboard_30d', | |
| 1323 | - 'intelligent_dashboard_90d', | |
| 1324 | - 'intelligent_opportunities_7d', | |
| 1325 | - 'intelligent_opportunities_30d', | |
| 1326 | - 'intelligent_opportunities_90d' | |
| 1327 | - ]; | |
| 1328 | - | |
| 1329 | - $cleared = 0; | |
| 1330 | - foreach ($intelligence_cache_keys as $key) { | |
| 1331 | - if (delete_transient($key)) { | |
| 1332 | - $cleared++; | |
| 1333 | - } | |
| 1334 | - } | |
| 1335 | - | |
| 1336 | - return [ | |
| 1337 | - 'success' => true, | |
| 1338 | - 'message' => "Cleared {$cleared} intelligence cache entries", | |
| 1339 | - 'cleared_count' => $cleared, | |
| 1340 | - 'timestamp' => current_time('mysql') | |
| 1341 | - ]; | |
| 1342 | 1059 | } |
| 1343 | 1060 | } |