PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/seo/class-analytics-manager.php +166 -454 2.0.02.7.0 View file →
@@ -16,9 +16,8 @@
16 16
17 17 namespace ThinkRank\SEO;
18 18
19 19 use ThinkRank\Core\Settings_Manager;
20 -use ThinkRank\Core\Plan_Config;
21 20 use ThinkRank\Integrations\Google_Analytics_Client;
22 21 use ThinkRank\Integrations\Google_Search_Console_Client;
23 22 use ThinkRank\Integrations\Google_PageSpeed_Client;
24 23 use ThinkRank\Integrations\Google_Search_Analytics_Client;
@@ -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
@@ -177,8 +214,43 @@
177 214 return $this->get_setting('search_console_property', get_site_url());
178 215 }
179 216
180 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 + /**
181 253 * Initialize Google API clients
182 254 * Following AI_Manager client initialization pattern
183 255 *
184 256 * @return void
@@ -375,10 +447,30 @@
375 447 // Calculate absolute expiration time (created + relative seconds)
376 448 $expiration_time = $created + $expires_in;
377 449
378 450 // Refresh if forced, expired, or expiring within 5 minutes (300 seconds)
379 - if ($force || $current_time >= ($expiration_time - 300)) {
451 + if (!$force && $current_time < ($expiration_time - 300)) {
452 + return;
453 + }
380 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 {
381 473 // The proxy owns the Google app credentials; we only ever hand it
382 474 // the refresh token and let it perform the exchange.
383 475 $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [
384 476 'headers' => [
@@ -389,12 +481,13 @@
389 481 'action' => 'refresh',
390 482 'refresh_token' => $refresh_token,
391 483 'site' => home_url(),
392 484 ]),
393 - 'timeout' => 30
485 + 'timeout' => self::REFRESH_TIMEOUT
394 486 ]);
395 487
396 488 if (is_wp_error($response)) {
489 + $this->back_off_refresh();
397 490 return;
398 491 }
399 492
400 493 $body = wp_remote_retrieve_body($response);
@@ -407,12 +500,15 @@
407 500 // the site is connected — otherwise the UI shows "Connected"
408 501 // while every API call 401s.
409 502 if (($data['error'] ?? '') === 'invalid_grant') {
410 503 Google_OAuth_Proxy::mark_revoked();
504 + return;
411 505 }
412 506
413 507 // Any other failure (network blip, proxy 502) is transient;
414 - // 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();
415 511 return;
416 512 }
417 513
418 514 // Update settings with new token data
@@ -428,15 +524,79 @@
428 524 'google_refresh_token' => $data['refresh_token']
429 525 ], 'integrations');
430 526 }
431 527
528 + // A success clears any backoff a previous failure left behind.
529 + delete_transient(self::REFRESH_BACKOFF);
530 +
432 531 // Drop the memoized settings merge so subsequent reads (e.g.
433 532 // re-initializing clients) see the fresh token.
434 533 $this->merged_settings = null;
534 + } finally {
535 + $this->release_refresh_lock();
435 536 }
436 537 }
437 538
438 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 + /**
439 599 * Test all Google API connections
440 600 * Following ThinkRank test_connection patterns
441 601 *
442 602 * @return array Connection test results
@@ -550,10 +710,8 @@
550 710 // 401s are re-thrown so the token-refresh retry below runs.
551 711 if ($this->analytics_client) {
552 712 try {
553 713 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
554 - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
555 - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
556 714 } catch (\Exception $ga_error) {
557 715 if ($ga_error->getCode() === 401) {
558 716 throw $ga_error;
559 717 }
@@ -611,10 +769,8 @@
611 769 $dashboard_data['search_performance'] = array_merge($search_performance, [
612 770 'totals' => $totals,
613 771 'position_distribution' => $position_distribution
614 772 ]);
615 -
616 - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
617 773 } // Closing Search Console block
618 774
619 775 // If successful, break loop
620 776 break;
@@ -812,44 +968,8 @@
812 968 }
813 969 }
814 970
815 971 /**
816 - * Get site indexing status
817 - *
818 - * @return array Indexing status data
819 - */
820 - public function get_indexing_status(): array {
821 - $cache_key = 'indexing_status';
822 - $cached_data = get_transient($cache_key);
823 -
824 - if ($cached_data !== false) {
825 - return $cached_data;
826 - }
827 -
828 - $indexing_data = [
829 - 'status' => 'unknown',
830 - 'last_updated' => current_time('mysql')
831 - ];
832 -
833 - try {
834 - if ($this->search_console_client) {
835 - $site_url = $this->get_setting('search_console_property', get_site_url());
836 - $indexing_data = $this->search_console_client->get_indexing_status($site_url);
837 - }
838 - } catch (\Exception $e) {
839 - $indexing_data['error'] = $e->getMessage();
840 - }
841 -
842 - // Cache successful results for 1 hour. Errors are never cached, so a
843 - // transient Google failure isn't served as "no data" for a full hour.
844 - if (empty($indexing_data['error'])) {
845 - set_transient($cache_key, $indexing_data, 3600);
846 - }
847 -
848 - return $indexing_data;
849 - }
850 -
851 - /**
852 972 * Force refresh of all cached data
853 973 *
854 974 * @return array Refresh results
855 975 */
@@ -866,11 +986,8 @@
866 986 'analytics_dashboard_v5_180d',
867 987 'seo_opportunities_7d',
868 988 'seo_opportunities_30d',
869 989 'seo_opportunities_90d',
870 - 'seo_insights_7d',
871 - 'seo_insights_30d',
872 - 'seo_insights_90d',
873 990 'indexing_status',
874 991 'thinkrank_dashboard_cwv'
875 992 ];
876 993
@@ -938,411 +1055,6 @@
938 1055 */
939 1056 public function cleanup_cache(): void {
940 1057 // WordPress handles transient cleanup automatically
941 1058 // This method is for future custom cache cleanup if needed
942 - }
943 -
944 - // ========================================
945 - // SEO Intelligence Enhancement Methods
946 - // ========================================
947 -
948 - /**
949 - * Get intelligent dashboard data with trends and insights
950 - *
951 - * @param string $date_range Date range for analysis
952 - * @return array Enhanced dashboard data with intelligence
953 - */
954 - public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
955 - // Get base dashboard data
956 - $dashboard_data = $this->get_dashboard_data($date_range);
957 -
958 - // Check if there's an error in the data
959 - if (isset($dashboard_data['error'])) {
960 - return [
961 - 'success' => false,
962 - 'data' => null,
963 - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
964 - 'timestamp' => current_time('mysql')
965 - ];
966 - }
967 -
968 - // Check if we have real data available
969 - if (!$this->has_real_data($dashboard_data)) {
970 - return [
971 - 'success' => false,
972 - 'data' => null,
973 - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
974 - 'timestamp' => current_time('mysql')
975 - ];
976 - }
977 -
978 - // Intelligence engine is a Pro-only feature. The four SEO_* classes ship
979 - // in Free too (the PSR-4 autoloader would resolve them), so class_exists()
980 - // can't gate this — check the real Pro signal instead.
981 - if (!Plan_Config::is_pro()) {
982 - return [
983 - 'success' => false,
984 - 'data' => null,
985 - 'message' => 'Intelligent dashboard requires ThinkRank Pro.',
986 - 'timestamp' => current_time('mysql')
987 - ];
988 - }
989 -
990 - $trend_analyzer = new SEO_Trend_Analyzer();
991 - $scoring_engine = new SEO_Scoring_Engine();
992 - $insight_generator = new SEO_Insight_Generator();
993 -
994 - $data = $dashboard_data;
995 -
996 - // Generate trend analysis
997 - $current_data = $data;
998 - $historical_data = $this->get_historical_data($date_range);
999 -
1000 - $trends = [
1001 - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
1002 - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
1003 - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
1004 - ];
1005 -
1006 - // Calculate SEO health score
1007 - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
1008 -
1009 - // Generate insights
1010 - $insights = [
1011 - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
1012 - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
1013 - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
1014 - ];
1015 -
1016 - // Combine all intelligence data
1017 - $enhanced_data = array_merge($data, [
1018 - 'intelligence' => [
1019 - 'trends' => $trends,
1020 - 'seo_health_score' => $seo_health,
1021 - 'insights' => $insights,
1022 - 'last_analyzed' => current_time('mysql')
1023 - ]
1024 - ]);
1025 -
1026 - return [
1027 - 'success' => true,
1028 - 'data' => $enhanced_data,
1029 - 'message' => 'Intelligent dashboard data retrieved successfully'
1030 - ];
1031 - }
1032 -
1033 - /**
1034 - * Get intelligent SEO opportunities with prioritization
1035 - *
1036 - * @param string $date_range Date range for analysis
1037 - * @return array Enhanced opportunities with intelligence
1038 - */
1039 - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
1040 - // Get base opportunities data
1041 - $opportunities_data = $this->get_seo_opportunities($date_range);
1042 -
1043 - // Check if there's an error in the data
1044 - if (isset($opportunities_data['error'])) {
1045 - return [
1046 - 'success' => false,
1047 - 'data' => null,
1048 - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
1049 - 'timestamp' => current_time('mysql')
1050 - ];
1051 - }
1052 -
1053 - // The opportunities payload itself has no search_performance key — that
1054 - // data lives in the (cached) dashboard payload. Pull it from there both
1055 - // for the availability check and as input for the opportunity detectors;
1056 - // checking $opportunities_data['search_performance'] here used to make
1057 - // this method always bail with "No Search Console data available".
1058 - $dashboard_data = $this->get_dashboard_data($date_range);
1059 - $search_performance = $dashboard_data['search_performance'] ?? [];
1060 - $opportunities_data['search_performance'] = $search_performance;
1061 -
1062 - $has_search_data = !empty($search_performance['rows']) ||
1063 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1064 - ($search_performance['total_impressions'] ?? 0) > 0;
1065 -
1066 - if (!$has_search_data) {
1067 - return [
1068 - 'success' => false,
1069 - 'data' => null,
1070 - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
1071 - 'timestamp' => current_time('mysql')
1072 - ];
1073 - }
1074 -
1075 - // Intelligence engine is a Pro-only feature — gate on the real Pro signal,
1076 - // not class_exists() (the classes ship in Free and would autoload).
1077 - if (!Plan_Config::is_pro()) {
1078 - return [
1079 - 'success' => false,
1080 - 'data' => null,
1081 - 'message' => 'Intelligent opportunities require ThinkRank Pro.',
1082 - 'timestamp' => current_time('mysql')
1083 - ];
1084 - }
1085 -
1086 - $opportunity_detector = new SEO_Opportunity_Detector();
1087 - $scoring_engine = new SEO_Scoring_Engine();
1088 -
1089 - $data = $opportunities_data;
1090 -
1091 - // Detect intelligent opportunities
1092 - $search_console_data = $data['search_performance'] ?? [];
1093 - $analytics_data = $data;
1094 -
1095 - $intelligent_opportunities = [
1096 - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
1097 - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
1098 - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
1099 - ];
1100 -
1101 - // Prioritize all opportunities. prioritize_opportunities() expects
1102 - // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list.
1103 - $opportunities_by_category = [
1104 - 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
1105 - 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
1106 - 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [],
1107 - ];
1108 - $all_opportunities = array_merge(...array_values($opportunities_by_category));
1109 -
1110 - $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category);
1111 - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
1112 -
1113 - // Enhance original data with intelligence
1114 - $enhanced_data = array_merge($data, [
1115 - 'intelligent_opportunities' => $intelligent_opportunities,
1116 - 'prioritized_opportunities' => $prioritized,
1117 - 'impact_effort_matrix' => $impact_matrix,
1118 - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
1119 - 'last_analyzed' => current_time('mysql')
1120 - ]);
1121 -
1122 - return [
1123 - 'success' => true,
1124 - 'data' => $enhanced_data,
1125 - 'message' => 'Intelligent SEO opportunities retrieved successfully'
1126 - ];
1127 - }
1128 -
1129 - /**
1130 - * Get SEO performance insights
1131 - *
1132 - * @param string $date_range Date range for analysis
1133 - * @return array SEO insights data
1134 - */
1135 - public function get_seo_insights(string $date_range = '30d'): array {
1136 - $cache_key = "seo_insights_{$date_range}";
1137 - $cached_data = get_transient($cache_key);
1138 -
1139 - if ($cached_data !== false) {
1140 - return [
1141 - 'success' => true,
1142 - 'data' => $cached_data,
1143 - 'cached' => true,
1144 - 'message' => 'SEO insights retrieved from cache'
1145 - ];
1146 - }
1147 -
1148 - try {
1149 - // Get dashboard data for analysis
1150 - $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
1151 -
1152 - if (!$dashboard_result['success']) {
1153 - return $dashboard_result;
1154 - }
1155 -
1156 - $dashboard_data = $dashboard_result['data'];
1157 - $intelligence = $dashboard_data['intelligence'] ?? [];
1158 -
1159 - // Insights are a Pro-only feature — gate on the real Pro signal,
1160 - // not class_exists() (SEO_Insight_Generator ships in Free too).
1161 - if (!Plan_Config::is_pro()) {
1162 - return [
1163 - 'success' => false,
1164 - 'data' => null,
1165 - 'message' => 'SEO insights require ThinkRank Pro.',
1166 - 'timestamp' => current_time('mysql')
1167 - ];
1168 - }
1169 -
1170 - $insight_generator = new SEO_Insight_Generator();
1171 -
1172 - // Collect all insights
1173 - $all_insights = [];
1174 -
1175 - if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
1176 - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
1177 - }
1178 -
1179 - if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
1180 - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
1181 - }
1182 -
1183 - if (!empty($intelligence['insights']['content_insights']['insights'])) {
1184 - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
1185 - }
1186 -
1187 - // Format and prioritize insights
1188 - $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
1189 - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
1190 -
1191 - $insights_data = [
1192 - 'insights' => $prioritized_insights['prioritized_insights'],
1193 - 'summary' => [
1194 - 'total_insights' => count($formatted_insights),
1195 - 'high_impact_count' => $prioritized_insights['high_impact_count'],
1196 - 'action_required_count' => $prioritized_insights['action_required_count']
1197 - ],
1198 - 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
1199 - 'generated_at' => current_time('mysql')
1200 - ];
1201 -
1202 - // Cache the results
1203 - set_transient($cache_key, $insights_data, $this->cache_duration);
1204 -
1205 - return [
1206 - 'success' => true,
1207 - 'data' => $insights_data,
1208 - 'cached' => false,
1209 - 'message' => 'SEO insights generated successfully'
1210 - ];
1211 - } catch (\Exception $e) {
1212 - return [
1213 - 'success' => false,
1214 - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
1215 - 'data' => null
1216 - ];
1217 - }
1218 - }
1219 -
1220 - /**
1221 - * Check if real analytics data is available
1222 - *
1223 - * @param array $dashboard_data Dashboard data to check
1224 - * @return bool True if real data is available
1225 - */
1226 - private function has_real_data(array $dashboard_data): bool {
1227 - // Check if we have meaningful traffic data
1228 - $traffic = $dashboard_data['traffic'] ?? [];
1229 - $search_performance = $dashboard_data['search_performance'] ?? [];
1230 -
1231 - $has_traffic = !empty($traffic) && (
1232 - ($traffic['sessions'] ?? 0) > 0 ||
1233 - ($traffic['pageviews'] ?? 0) > 0 ||
1234 - ($traffic['active_users'] ?? 0) > 0
1235 - );
1236 -
1237 - $has_search_data = !empty($search_performance) && (
1238 - !empty($search_performance['rows']) ||
1239 - ($search_performance['total_clicks'] ?? 0) > 0 ||
1240 - ($search_performance['total_impressions'] ?? 0) > 0
1241 - );
1242 -
1243 - return $has_traffic || $has_search_data;
1244 - }
1245 -
1246 - /**
1247 - * Get historical data for trend comparison
1248 - *
1249 - * @param string $current_range Current date range
1250 - * @return array Historical data
1251 - */
1252 - private function get_historical_data(string $current_range): array {
1253 - // Calculate previous period based on current range
1254 - $previous_range = $this->calculate_previous_period($current_range);
1255 -
1256 - // Try to get actual historical data from previous period
1257 - $historical_data = $this->get_dashboard_data($previous_range);
1258 -
1259 - // Return the actual historical data (may be empty if no real data available)
1260 - return [
1261 - 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
1262 - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
1263 - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
1264 - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
1265 - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
1266 - ];
1267 - }
1268 -
1269 - /**
1270 - * Calculate previous period for comparison
1271 - *
1272 - * @param string $current_range Current range
1273 - * @return string Previous period range
1274 - */
1275 - private function calculate_previous_period(string $current_range): string {
1276 - // Simple mapping for now - could be enhanced with actual date calculations
1277 - $period_mapping = [
1278 - '7d' => '14d',
1279 - '30d' => '60d',
1280 - '90d' => '180d'
1281 - ];
1282 -
1283 - return $period_mapping[$current_range] ?? '60d';
1284 - }
1285 -
1286 - /**
1287 - * Generate opportunity summary
1288 - *
1289 - * @param array $opportunities All opportunities
1290 - * @return array Opportunity summary
1291 - */
1292 - private function generate_opportunity_summary(array $opportunities): array {
1293 - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
1294 - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
1295 - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
1296 -
1297 - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
1298 -
1299 - $potential_clicks = 0;
1300 - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
1301 - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
1302 - }
1303 -
1304 - return [
1305 - 'total_opportunities' => $total_opportunities,
1306 - 'quick_wins_count' => $quick_wins_count,
1307 - 'content_opportunities_count' => $content_opportunities_count,
1308 - 'keyword_opportunities_count' => $keyword_opportunities_count,
1309 - 'potential_additional_clicks' => $potential_clicks,
1310 - 'priority_recommendation' => $quick_wins_count > 0 ?
1311 - 'Focus on quick wins first for immediate impact' :
1312 - 'Focus on content optimization for long-term growth'
1313 - ];
1314 - }
1315 -
1316 - /**
1317 - * Clear intelligence cache
1318 - *
1319 - * @return array Clear result
1320 - */
1321 - public function clear_intelligence_cache(): array {
1322 - $intelligence_cache_keys = [
1323 - 'seo_insights_7d',
1324 - 'seo_insights_30d',
1325 - 'seo_insights_90d',
1326 - 'intelligent_dashboard_7d',
1327 - 'intelligent_dashboard_30d',
1328 - 'intelligent_dashboard_90d',
1329 - 'intelligent_opportunities_7d',
1330 - 'intelligent_opportunities_30d',
1331 - 'intelligent_opportunities_90d'
1332 - ];
1333 -
1334 - $cleared = 0;
1335 - foreach ($intelligence_cache_keys as $key) {
1336 - if (delete_transient($key)) {
1337 - $cleared++;
1338 - }
1339 - }
1340 -
1341 - return [
1342 - 'success' => true,
1343 - 'message' => "Cleared {$cleared} intelligence cache entries",
1344 - 'cleared_count' => $cleared,
1345 - 'timestamp' => current_time('mysql')
1346 - ];
1347 1059 }
1348 1060 }