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 +723 -502 1.0.22.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Analytics Manager Class
4 5 *
5 6 * Coordinates Google API integrations for SEO analytics data collection,
@@ -18,8 +19,10 @@
18 19 use ThinkRank\Core\Settings_Manager;
19 20 use ThinkRank\Integrations\Google_Analytics_Client;
20 21 use ThinkRank\Integrations\Google_Search_Console_Client;
21 22 use ThinkRank\Integrations\Google_PageSpeed_Client;
23 +use ThinkRank\Integrations\Google_Search_Analytics_Client;
24 +use ThinkRank\Integrations\Google_OAuth_Proxy;
22 25
23 26 // Prevent direct access
24 27 if (!defined('ABSPATH')) {
25 28 exit;
@@ -56,8 +59,15 @@
56 59 */
57 60 private ?Google_Search_Console_Client $search_console_client = null;
58 61
59 62 /**
63 + * Google Search Analytics client
64 + *
65 + * @var Google_Search_Analytics_Client|null
66 + */
67 + private ?Google_Search_Analytics_Client $search_analytics_client = null;
68 +
69 + /**
60 70 * Google PageSpeed client
61 71 *
62 72 * @var Google_PageSpeed_Client|null
63 73 */
@@ -70,8 +80,52 @@
70 80 */
71 81 private int $cache_duration;
72 82
73 83 /**
84 + * Static flag to prevent multiple token refreshes in the same request
85 + *
86 + * @var bool
87 + */
88 + private static bool $token_refreshed_this_request = false;
89 +
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 + /**
74 128 * Constructor
75 129 *
76 130 * @param Settings_Manager|null $settings_manager Settings manager instance
77 131 */
@@ -76,12 +130,10 @@
76 130 * @param Settings_Manager|null $settings_manager Settings manager instance
77 131 */
78 132 public function __construct(?Settings_Manager $settings_manager = null) {
79 133 $this->settings_manager = $settings_manager ?? new Settings_Manager();
80 - $this->cache_duration = (int) $this->get_setting('cache_duration', 3600);
81 -
82 - // Clear any existing cached insights to ensure new logic takes effect
83 - $this->clear_insights_cache();
134 + // Pro: daily refresh (86400s), Free: 3-day refresh (259200s)
135 + $this->cache_duration = defined('THINKRANK_PRO_VERSION') ? 86400 : 259200;
84 136 }
85 137
86 138 /**
87 139 * Initialize Analytics Manager
@@ -89,16 +141,116 @@
89 141 *
90 142 * @return void
91 143 */
92 144 public function init(): void {
93 - // Initialize Google API clients
94 - add_action('init', [$this, 'initialize_clients']);
145 + // Register custom cron interval (45 minutes)
146 + add_filter('cron_schedules', [$this, 'add_cron_intervals']);
95 147
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']);
151 +
152 + // Initialize token refresh scheduling
153 + add_action('init', [$this, 'init_token_refresh']);
154 +
155 + // Cron hook for token refresh
156 + add_action('thinkrank_google_token_refresh', [$this, 'refresh_access_token_cron']);
157 +
96 158 // Schedule cache cleanup
97 159 add_action('thinkrank_daily_cleanup', [$this, 'cleanup_cache']);
160 +
161 + // Cleanup cron on plugin deactivation
162 + register_deactivation_hook(THINKRANK_PLUGIN_FILE, [__CLASS__, 'deactivation_cleanup']);
98 163 }
99 164
100 165 /**
166 + * Add custom cron intervals
167 + *
168 + * @param array $schedules Existing cron schedules
169 + * @return array Modified cron schedules
170 + */
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+.
175 + $schedules['thinkrank_45min'] = [
176 + 'interval' => 2700, // 45 minutes in seconds
177 + 'display' => did_action('init')
178 + ? __('Every 45 Minutes', 'thinkrank')
179 + : 'Every 45 Minutes'
180 + ];
181 + return $schedules;
182 + }
183 +
184 + /**
185 + * Clean up cron events on plugin deactivation
186 + *
187 + * @return void
188 + */
189 + public static function deactivation_cleanup(): void {
190 + $timestamp = wp_next_scheduled('thinkrank_google_token_refresh');
191 + if ($timestamp) {
192 + wp_unschedule_event($timestamp, 'thinkrank_google_token_refresh');
193 + }
194 + }
195 +
196 + /**
197 + * Get the initialized Search Console client
198 + *
199 + * @return Google_Search_Console_Client|null
200 + */
201 + public function get_search_console_client(): ?Google_Search_Console_Client {
202 + if (!$this->search_console_client) {
203 + $this->initialize_clients();
204 + }
205 + return $this->search_console_client;
206 + }
207 +
208 + /**
209 + * Get the configured Search Console property URL
210 + *
211 + * @return string
212 + */
213 + public function get_property_url(): string {
214 + return $this->get_setting('search_console_property', get_site_url());
215 + }
216 +
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 + /**
101 253 * Initialize Google API clients
102 254 * Following AI_Manager client initialization pattern
103 255 *
104 256 * @return void
@@ -104,39 +256,347 @@
104 256 * @return void
105 257 */
106 258 public function initialize_clients(): void {
107 259 try {
108 - // Initialize Google Analytics client
109 - $ga_api_key = $this->get_setting('google_analytics_api_key');
110 - $ga_property_id = $this->get_setting('google_analytics_property_id');
111 -
112 - if (!empty($ga_api_key) && !empty($ga_property_id)) {
113 - $timeout = (int) $this->get_setting('api_timeout', 30);
114 - $this->analytics_client = new Google_Analytics_Client($ga_api_key, $ga_property_id, $timeout);
115 - }
260 + // Refresh token if needed (non-forced, checks expiration)
261 + $this->refresh_access_token();
116 262
117 263 // Initialize Search Console client
118 264 $gsc_api_key = $this->get_setting('google_search_console_api_key');
119 -
120 - if (!empty($gsc_api_key)) {
121 - $timeout = (int) $this->get_setting('api_timeout', 30);
122 - $this->search_console_client = new Google_Search_Console_Client($gsc_api_key, $timeout);
265 + $access_token = $this->get_setting('google_access_token');
266 +
267 + $timeout = (int) $this->get_setting('api_timeout', 30);
268 + $this->search_console_client = new Google_Search_Console_Client(
269 + $gsc_api_key ?: '',
270 + $timeout,
271 + !empty($access_token) ? $access_token : null
272 + );
273 +
274 + // Initialize Search Analytics client
275 + $this->search_analytics_client = new Google_Search_Analytics_Client(
276 + $gsc_api_key ?: '',
277 + $timeout,
278 + !empty($access_token) ? $access_token : null
279 + );
280 +
281 + // Initialize PageSpeed client. PSI is a public API — it uses the
282 + // site's own API key (or keyless per-IP quota), never the shared
283 + // OAuth token, which would bill every install's Lighthouse runs
284 + // to one exhausted Google Cloud project (429 for everyone).
285 + // Shorter timeout here: the dashboard CWV card fetches in-request
286 + // on a cold cache and must not stall the whole dashboard payload.
287 + $this->pagespeed_client = Google_PageSpeed_Client::for_site(25);
288 +
289 + // Initialize Google Analytics (GA4) client when a property has
290 + // been selected. The GA settings UI stores the property in the
291 + // Admin API's "properties/XXXXXXXX" form, which is exactly what
292 + // the Data API endpoints expect.
293 + $ga_property = (string) $this->get_setting('seo_analytics_google_analytics_property_id');
294 + if (!empty($access_token) && $ga_property !== '') {
295 + if (strpos($ga_property, 'properties/') !== 0) {
296 + $ga_property = 'properties/' . $ga_property;
297 + }
298 + $this->analytics_client = new Google_Analytics_Client('', $ga_property, $timeout, $access_token);
123 299 }
300 + } catch (\Exception $e) {
301 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
302 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
303 + error_log('ThinkRank Analytics Init Error: ' . $e->getMessage());
304 + }
305 + }
306 + }
124 307
125 - // Initialize PageSpeed client
126 - $ps_api_key = $this->get_setting('google_pagespeed_api_key');
127 -
128 - if (!empty($ps_api_key)) {
129 - $timeout = (int) $this->get_setting('api_timeout', 30);
130 - $this->pagespeed_client = new Google_PageSpeed_Client($ps_api_key, $timeout);
308 + /**
309 + * Initialize token refresh scheduling
310 + * Also migrates old absolute-timestamp expires_in values to relative seconds
311 + *
312 + * @return void
313 + */
314 + public function init_token_refresh(): void {
315 + $access_token = $this->get_setting('google_access_token');
316 + $refresh_token = $this->get_setting('google_refresh_token');
317 +
318 + if (empty($access_token) || empty($refresh_token)) {
319 + return;
320 + }
321 +
322 + // Migrate old expires_in values stored as absolute timestamps
323 + $this->maybe_migrate_expires_in();
324 +
325 + // Schedule recurring hourly cron for token refresh
326 + $this->schedule_token_refresh();
327 + }
328 +
329 + /**
330 + * Migrate old expires_in values from absolute timestamps to relative seconds
331 + *
332 + * Old callback.php stored expires_in as time() + token->expires_in (e.g., 1771330205).
333 + * New behavior stores raw seconds from Google (e.g., 3599).
334 + *
335 + * @return void
336 + */
337 + private function maybe_migrate_expires_in(): void {
338 + $expires_in = (int) $this->get_setting('google_token_expires_in');
339 + $created = (int) $this->get_setting('google_token_created');
340 +
341 + // Google tokens expire in 3600 seconds max. If stored value is > 86400,
342 + // it's almost certainly the old absolute timestamp format.
343 + if ($expires_in > 86400 && $created > 0) {
344 + $relative = $expires_in - $created;
345 + if ($relative > 0 && $relative <= 7200) {
346 + // Valid relative value, save the corrected value
347 + $this->settings_manager->update_settings([
348 + 'google_token_expires_in' => $relative
349 + ], 'integrations');
350 + } else {
351 + // Can't reliably compute, default to standard 3600
352 + $this->settings_manager->update_settings([
353 + 'google_token_expires_in' => 3600
354 + ], 'integrations');
131 355 }
356 + $this->merged_settings = null;
357 + }
358 + }
132 359
133 - } catch (\Exception $e) {
134 - // Client initialization failed, will be handled later
360 + /**
361 + * Schedule recurring cron for token refresh (every 45 minutes)
362 + *
363 + * Uses WP recurring cron instead of single events for reliability.
364 + * The cron callback checks expiration and only refreshes when needed.
365 + * Using 45-minute interval ensures the cron always fires before
366 + * Google's ~60-minute token expiry window.
367 + *
368 + * @return void
369 + */
370 + public function schedule_token_refresh(): void {
371 + $next = wp_next_scheduled('thinkrank_google_token_refresh');
372 +
373 + // If already scheduled with the old 'hourly' interval, reschedule with 45min
374 + if ($next) {
375 + // Check if it's using the old interval by looking at the schedule
376 + $crons = _get_cron_array();
377 + foreach ($crons as $timestamp => $cron_hooks) {
378 + if (isset($cron_hooks['thinkrank_google_token_refresh'])) {
379 + foreach ($cron_hooks['thinkrank_google_token_refresh'] as $hash => $args) {
380 + if (($args['schedule'] ?? '') === 'hourly') {
381 + // Remove old hourly schedule and re-add with 45min
382 + wp_unschedule_event($timestamp, 'thinkrank_google_token_refresh');
383 + $next = false; // Will be rescheduled below
384 + }
385 + }
386 + break;
387 + }
388 + }
135 389 }
390 +
391 + if (!$next) {
392 + wp_schedule_event(time(), 'thinkrank_45min', 'thinkrank_google_token_refresh');
393 + }
136 394 }
137 395
138 396 /**
397 + * Cron callback for token refresh
398 + * Called every 45 minutes; only refreshes if token is expired or expiring soon.
399 + *
400 + * @return void
401 + */
402 + public function refresh_access_token_cron(): void {
403 + $this->refresh_access_token();
404 + }
405 +
406 + /**
407 + * Ensure the Google access token is fresh before making API calls.
408 + *
409 + * This is a static convenience method that can be called from any endpoint
410 + * (including the Pro plugin) before making Google API requests.
411 + * Uses a per-request flag to avoid redundant refreshes when multiple
412 + * endpoints are called in the same HTTP request.
413 + *
414 + * @since 1.6.0
415 + * @return void
416 + */
417 + public static function ensure_fresh_token(): void {
418 + // Only refresh once per HTTP request to avoid parallel race conditions
419 + if (self::$token_refreshed_this_request) {
420 + return;
421 + }
422 +
423 + $manager = new self();
424 + $manager->refresh_access_token();
425 + self::$token_refreshed_this_request = true;
426 + }
427 +
428 + /**
429 + * Refresh OAuth access token if expired or expiring soon
430 + *
431 + * @since 1.5.0
432 + * @param bool $force Force refresh even if not expired
433 + * @return void
434 + */
435 + public function refresh_access_token(bool $force = false): void {
436 + $refresh_token = $this->get_setting('google_refresh_token');
437 +
438 + // If no refresh token, we can't refresh
439 + if (empty($refresh_token)) {
440 + return;
441 + }
442 +
443 + $expires_in = (int) $this->get_setting('google_token_expires_in');
444 + $created = (int) $this->get_setting('google_token_created');
445 + $current_time = time();
446 +
447 + // Calculate absolute expiration time (created + relative seconds)
448 + $expiration_time = $created + $expires_in;
449 +
450 + // Refresh if forced, expired, or expiring within 5 minutes (300 seconds)
451 + if (!$force && $current_time < ($expiration_time - 300)) {
452 + return;
453 + }
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 {
473 + // The proxy owns the Google app credentials; we only ever hand it
474 + // the refresh token and let it perform the exchange.
475 + $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [
476 + 'headers' => [
477 + 'Content-Type' => 'application/json',
478 + 'Accept' => 'application/json',
479 + ],
480 + 'body' => wp_json_encode([
481 + 'action' => 'refresh',
482 + 'refresh_token' => $refresh_token,
483 + 'site' => home_url(),
484 + ]),
485 + 'timeout' => self::REFRESH_TIMEOUT
486 + ]);
487 +
488 + if (is_wp_error($response)) {
489 + $this->back_off_refresh();
490 + return;
491 + }
492 +
493 + $body = wp_remote_retrieve_body($response);
494 + $data = json_decode($body, true);
495 +
496 + if (empty($data['access_token'])) {
497 + // invalid_grant is terminal: the user revoked access in their
498 + // Google account, or the refresh token was superseded by a
499 + // newer grant. Retrying can never succeed, so stop pretending
500 + // the site is connected — otherwise the UI shows "Connected"
501 + // while every API call 401s.
502 + if (($data['error'] ?? '') === 'invalid_grant') {
503 + Google_OAuth_Proxy::mark_revoked();
504 + return;
505 + }
506 +
507 + // Any other failure (network blip, proxy 502) is transient;
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();
511 + return;
512 + }
513 +
514 + // Update settings with new token data
515 + $this->settings_manager->update_settings([
516 + 'google_access_token' => $data['access_token'],
517 + 'google_token_created' => $current_time,
518 + 'google_token_expires_in' => (int) ($data['expires_in'] ?? 3600)
519 + ], 'integrations');
520 +
521 + // Also update refresh token if a new one was returned
522 + if (!empty($data['refresh_token'])) {
523 + $this->settings_manager->update_settings([
524 + 'google_refresh_token' => $data['refresh_token']
525 + ], 'integrations');
526 + }
527 +
528 + // A success clears any backoff a previous failure left behind.
529 + delete_transient(self::REFRESH_BACKOFF);
530 +
531 + // Drop the memoized settings merge so subsequent reads (e.g.
532 + // re-initializing clients) see the fresh token.
533 + $this->merged_settings = null;
534 + } finally {
535 + $this->release_refresh_lock();
536 + }
537 + }
538 +
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 + /**
139 599 * Test all Google API connections
140 600 * Following ThinkRank test_connection patterns
141 601 *
142 602 * @return array Connection test results
@@ -207,14 +667,21 @@
207 667 * Combines data from all Google APIs with caching
208 668 *
209 669 * @param string $date_range Date range for data
210 670 * @return array Dashboard data
671 + *
672 + * @throws \Exception On failure.
211 673 */
212 674 public function get_dashboard_data(string $date_range = '30d'): array {
213 - $cache_key = "analytics_dashboard_{$date_range}";
675 + $cache_key = "analytics_dashboard_v5_{$date_range}";
214 676 $cached_data = get_transient($cache_key);
215 677
216 678 if ($cached_data !== false) {
679 + // Core Web Vitals are cached separately with a much shorter
680 + // lifetime than the GSC data (and failures are never cached), so
681 + // a transient PageSpeed failure can't blank the CWV card for the
682 + // dashboard cache's full 1-3 day TTL.
683 + $cached_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
217 684 return $cached_data;
218 685 }
219 686
220 687 $dashboard_data = [
@@ -224,41 +691,163 @@
224 691 'last_updated' => current_time('mysql'),
225 692 'date_range' => $date_range
226 693 ];
227 694
228 - try {
229 - // Get Google Analytics traffic data
230 - if ($this->analytics_client) {
231 - $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
232 - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range);
233 - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range);
234 - }
695 + $retry_count = 0;
696 + $max_retries = 1;
235 697
236 - // Get Search Console data
237 - if ($this->search_console_client) {
238 - $site_url = $this->get_setting('search_console_property', get_site_url());
239 - $dashboard_data['search_performance'] = $this->search_console_client->get_search_performance($site_url, $date_range);
240 - $dashboard_data['top_queries'] = $this->search_console_client->get_top_queries($site_url, 10);
241 - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10);
698 + while ($retry_count <= $max_retries) {
699 + try {
700 + // Ensure clients are initialized (lazy load) before any of
701 + // them are used — this also builds the GA4 client when a
702 + // property is configured.
703 + if (!$this->search_console_client || !$this->search_analytics_client) {
704 + $this->initialize_clients();
705 + }
706 +
707 + // Get Google Analytics traffic data. GA is optional — an
708 + // isolated failure (misconfigured property, missing scope)
709 + // must not abort the Search Console portion of the dashboard.
710 + // 401s are re-thrown so the token-refresh retry below runs.
711 + if ($this->analytics_client) {
712 + try {
713 + $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
714 + } catch (\Exception $ga_error) {
715 + if ($ga_error->getCode() === 401) {
716 + throw $ga_error;
717 + }
718 + $dashboard_data['traffic'] = [];
719 + $dashboard_data['traffic_error'] = $ga_error->getMessage();
720 + }
721 + }
722 +
723 + // Get Search Console data
724 + if ($this->search_console_client) {
725 + $site_url = $this->get_setting('search_console_property', get_site_url());
726 + // Get totals
727 + $totals = $this->search_console_client->get_search_totals($site_url, $date_range);
728 +
729 + // Get performance data (keywords) using new client
730 + if ($this->search_analytics_client) {
731 + // GSC data has a 2-day delay; use D-2 as end_date to match the GSC dashboard.
732 + $days = (int) str_replace('d', '', $date_range);
733 + $end_date = gmdate('Y-m-d', strtotime('-2 days'));
734 + $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date)));
735 +
736 + $search_performance = $this->search_analytics_client->get_search_analytics_data(
737 + $site_url,
738 + $start_date,
739 + $end_date,
740 + ['query'],
741 + 1000
742 + );
743 + } else {
744 + // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
745 + $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
746 + }
747 +
748 + // Calculate position distribution
749 + $position_distribution = [
750 + 'top_3' => 0,
751 + '4_10' => 0,
752 + '10_50' => 0,
753 + '51_100' => 0
754 + ];
755 +
756 + foreach ($search_performance['rows'] ?? [] as $row) {
757 + $position = $row['position'] ?? 0;
758 + if ($position <= 3) {
759 + $position_distribution['top_3']++;
760 + } elseif ($position <= 10) {
761 + $position_distribution['4_10']++;
762 + } elseif ($position <= 50) {
763 + $position_distribution['10_50']++;
764 + } elseif ($position <= 100) {
765 + $position_distribution['51_100']++;
766 + }
767 + }
768 +
769 + $dashboard_data['search_performance'] = array_merge($search_performance, [
770 + 'totals' => $totals,
771 + 'position_distribution' => $position_distribution
772 + ]);
773 + } // Closing Search Console block
774 +
775 + // If successful, break loop
776 + break;
777 + } catch (\Exception $e) {
778 + // Check for 401 error
779 + if ($e->getCode() === 401 && $retry_count < $max_retries) {
780 + $this->refresh_access_token(true); // Force refresh
781 +
782 + // Re-initialize clients with new token
783 + $this->initialize_clients();
784 +
785 + $retry_count++;
786 + continue;
787 + }
788 + $dashboard_data['error'] = $e->getMessage();
789 + break;
242 790 }
791 + }
243 792
244 - // Get Core Web Vitals data
245 - if ($this->pagespeed_client) {
246 - $site_url = get_site_url();
247 - $dashboard_data['core_web_vitals'] = $this->pagespeed_client->get_core_web_vitals($site_url);
248 - }
793 + // Add last updated timestamp
794 + $dashboard_data['last_updated'] = current_time('mysql');
249 795
250 - } catch (\Exception $e) {
251 - $dashboard_data['error'] = $e->getMessage();
796 + // Cache the results — but never cache an error payload, otherwise a
797 + // transient failure (e.g. a Google API 401) would be served from the
798 + // cache for the full TTL even after the underlying issue is fixed.
799 + // Core Web Vitals are deliberately NOT part of this cache (see below).
800 + if (empty($dashboard_data['error'])) {
801 + set_transient($cache_key, $dashboard_data, $this->cache_duration);
252 802 }
253 803
254 - // Cache the results
255 - set_transient($cache_key, $dashboard_data, $this->cache_duration);
804 + // Merge Core Web Vitals from their own short-lived cache after the
805 + // long-lived GSC payload has been stored.
806 + $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
256 807
257 808 return $dashboard_data;
258 809 }
259 810
260 811 /**
812 + * Get Core Web Vitals for the analytics dashboard, cached independently
813 + * of the dashboard payload.
814 + *
815 + * Successful results are cached for 1 hour; failures are never cached
816 + * here (the PageSpeed client itself remembers failures for a few minutes
817 + * to avoid re-blocking requests on a broken URL), so CWV recovers as soon
818 + * as PageSpeed does instead of staying empty for the dashboard cache's
819 + * 1-3 day TTL.
820 + *
821 + * @return array Core Web Vitals data, or an error payload
822 + */
823 + private function get_dashboard_core_web_vitals(): array {
824 + $cached = get_transient('thinkrank_dashboard_cwv');
825 + if (is_array($cached)) {
826 + return $cached;
827 + }
828 +
829 + if (!$this->pagespeed_client) {
830 + $this->initialize_clients();
831 + }
832 +
833 + if (!$this->pagespeed_client) {
834 + return [];
835 + }
836 +
837 + try {
838 + $core_web_vitals = $this->pagespeed_client->get_core_web_vitals(get_site_url());
839 + set_transient('thinkrank_dashboard_cwv', $core_web_vitals, HOUR_IN_SECONDS);
840 + return $core_web_vitals;
841 + } catch (\Exception $psi_error) {
842 + return [
843 + 'error' => $psi_error->getMessage(),
844 + 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
845 + ];
846 + }
847 + }
848 +
849 + /**
261 850 * Get SEO opportunities using Search Console data
262 851 *
263 852 * @param string $date_range Date range for analysis
264 853 * @return array SEO opportunities
@@ -277,43 +866,74 @@
277 866 'device_insights' => [],
278 867 'last_updated' => current_time('mysql')
279 868 ];
280 869
281 - try {
282 - if ($this->search_console_client) {
283 - $site_url = $this->get_setting('search_console_property', get_site_url());
284 -
285 - // Get keyword opportunities
286 - $opportunities['keyword_opportunities'] = $this->search_console_client->get_keyword_opportunities($site_url, $date_range);
287 -
288 - // Get device performance insights
289 - $opportunities['device_insights'] = $this->search_console_client->get_device_performance($site_url, $date_range);
290 -
291 - // Get search appearance data
292 - $opportunities['search_appearance'] = $this->search_console_client->get_search_appearance($site_url, $date_range);
870 + $retry_count = 0;
871 + $max_retries = 1;
872 +
873 + while ($retry_count <= $max_retries) {
874 + try {
875 + if ($this->search_console_client) {
876 + $site_url = $this->get_setting('search_console_property', get_site_url());
877 +
878 + // Get keyword opportunities
879 + $opportunities['keyword_opportunities'] = $this->search_console_client->get_keyword_opportunities($site_url, $date_range);
880 +
881 + // Get device performance insights
882 + $opportunities['device_insights'] = $this->search_console_client->get_device_performance($site_url, $date_range);
883 +
884 + // Get search appearance data
885 + $opportunities['search_appearance'] = $this->search_console_client->get_search_appearance($site_url, $date_range);
886 + }
887 +
888 + // If successful, break loop
889 + break;
890 + } catch (\Exception $e) {
891 + // Check for 401 error
892 + if ($e->getCode() === 401 && $retry_count < $max_retries) {
893 + $this->refresh_access_token(true); // Force refresh
894 +
895 + // Re-initialize clients with new token
896 + $this->initialize_clients();
897 +
898 + $retry_count++;
899 + continue;
900 + }
901 +
902 + $opportunities['error'] = $e->getMessage();
903 + break;
293 904 }
905 + }
294 906
295 - } catch (\Exception $e) {
296 - $opportunities['error'] = $e->getMessage();
907 + // Cache the results — but never cache an error payload (see
908 + // get_dashboard_data() for rationale).
909 + if (empty($opportunities['error'])) {
910 + set_transient($cache_key, $opportunities, $this->cache_duration);
297 911 }
298 912
299 - // Cache the results
300 - set_transient($cache_key, $opportunities, $this->cache_duration);
301 -
302 913 return $opportunities;
303 914 }
304 915
305 916 /**
306 - * Get setting value from integrations category
307 - * Following ThinkRank settings patterns
917 + * Memoized merge of the two settings categories this manager reads from.
918 + * Rebuilt when settings are updated through update_settings() below.
308 919 *
309 - * @param string $key Setting key
310 - * @param mixed $default Default value
311 - * @return mixed Setting value
920 + * @var array|null
312 921 */
313 - private function get_setting(string $key, $default = '') {
314 - $integrations_settings = $this->settings_manager->get_settings('integrations');
315 - return $integrations_settings[$key] ?? $default;
922 + private ?array $merged_settings = null;
923 +
924 + private function get_setting(string $key, $fallback = '') {
925 + if ($this->merged_settings === null) {
926 + // Merge settings to allow access to both categories. Memoized:
927 + // this getter is called many times per request and each category
928 + // read decrypts every sensitive option again.
929 + $this->merged_settings = array_merge(
930 + $this->settings_manager->get_settings('integrations'),
931 + $this->settings_manager->get_settings('seo_analytics')
932 + );
933 + }
934 +
935 + return $this->merged_settings[$key] ?? $fallback;
316 936 }
317 937
318 938 /**
319 939 * One-click setup for Google Search Console verification
@@ -334,13 +954,13 @@
334 954 $verification_result = $this->search_console_client->verify_site($site_url);
335 955
336 956 if ($verification_result['success']) {
337 957 // Update settings with verified site URL
338 - $this->settings_manager->update_setting('integrations', 'search_console_property', $site_url);
958 + $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
959 + $this->merged_settings = null;
339 960 }
340 961
341 962 return $verification_result;
342 -
343 963 } catch (\Exception $e) {
344 964 return [
345 965 'success' => false,
346 966 'message' => $e->getMessage()
@@ -348,61 +968,45 @@
348 968 }
349 969 }
350 970
351 971 /**
352 - * Get site indexing status
353 - *
354 - * @return array Indexing status data
355 - */
356 - public function get_indexing_status(): array {
357 - $cache_key = 'indexing_status';
358 - $cached_data = get_transient($cache_key);
359 -
360 - if ($cached_data !== false) {
361 - return $cached_data;
362 - }
363 -
364 - $indexing_data = [
365 - 'status' => 'unknown',
366 - 'last_updated' => current_time('mysql')
367 - ];
368 -
369 - try {
370 - if ($this->search_console_client) {
371 - $site_url = $this->get_setting('search_console_property', get_site_url());
372 - $indexing_data = $this->search_console_client->get_indexing_status($site_url);
373 - }
374 -
375 - } catch (\Exception $e) {
376 - $indexing_data['error'] = $e->getMessage();
377 - }
378 -
379 - // Cache for 1 hour
380 - set_transient($cache_key, $indexing_data, 3600);
381 -
382 - return $indexing_data;
383 - }
384 -
385 - /**
386 972 * Force refresh of all cached data
387 973 *
388 974 * @return array Refresh results
389 975 */
390 976 public function refresh_data(): array {
391 - // Clear all analytics-related transients
977 + // Clear all analytics-related transients, including the previous-period
978 + // ranges used for trend comparison (14d/60d/180d) and the separately
979 + // cached Core Web Vitals payload.
392 980 $cache_keys = [
393 - 'analytics_dashboard_7d',
394 - 'analytics_dashboard_30d',
395 - 'analytics_dashboard_90d',
981 + 'analytics_dashboard_v5_7d',
982 + 'analytics_dashboard_v5_30d',
983 + 'analytics_dashboard_v5_90d',
984 + 'analytics_dashboard_v5_14d',
985 + 'analytics_dashboard_v5_60d',
986 + 'analytics_dashboard_v5_180d',
396 987 'seo_opportunities_7d',
397 988 'seo_opportunities_30d',
398 989 'seo_opportunities_90d',
399 - 'seo_insights_7d',
400 - 'seo_insights_30d',
401 - 'seo_insights_90d',
402 - 'indexing_status'
990 + 'indexing_status',
991 + 'thinkrank_dashboard_cwv'
403 992 ];
404 993
994 + // Also clear PageSpeed-derived caches. Their keys are md5-derived from
995 + // URL + device, so compute them for the URL/device combinations the
996 + // plugin actually tests.
997 + foreach (array_unique([home_url(), get_site_url()]) as $url) {
998 + foreach (['mobile', 'desktop'] as $device) {
999 + $psi_hash = md5($url . '|' . $device);
1000 + $legacy_hash = md5($url . '_' . $device);
1001 + $cache_keys[] = 'thinkrank_psi_snapshot_' . $psi_hash;
1002 + $cache_keys[] = 'thinkrank_psi_failure_' . $psi_hash;
1003 + $cache_keys[] = 'thinkrank_core_web_vitals_' . $legacy_hash;
1004 + $cache_keys[] = 'thinkrank_opportunities_' . $legacy_hash;
1005 + $cache_keys[] = 'thinkrank_diagnostics_' . $legacy_hash;
1006 + }
1007 + }
1008 +
405 1009 $cleared = 0;
406 1010 foreach ($cache_keys as $key) {
407 1011 if (delete_transient($key)) {
408 1012 $cleared++;
@@ -417,25 +1021,8 @@
417 1021 ];
418 1022 }
419 1023
420 1024 /**
421 - * Clear insights cache specifically
422 - *
423 - * @return void
424 - */
425 - private function clear_insights_cache(): void {
426 - $insight_cache_keys = [
427 - 'seo_insights_7d',
428 - 'seo_insights_30d',
429 - 'seo_insights_90d'
430 - ];
431 -
432 - foreach ($insight_cache_keys as $key) {
433 - delete_transient($key);
434 - }
435 - }
436 -
437 - /**
438 1025 * Get client status for debugging
439 1026 *
440 1027 * @return array Client status information
441 1028 */
@@ -443,9 +1030,9 @@
443 1030 return [
444 1031 'google_analytics' => [
445 1032 'initialized' => !is_null($this->analytics_client),
446 1033 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
447 - 'property_id_configured' => !empty($this->get_setting('google_analytics_property_id'))
1034 + 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
448 1035 ],
449 1036 'search_console' => [
450 1037 'initialized' => !is_null($this->search_console_client),
451 1038 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
@@ -468,372 +1055,6 @@
468 1055 */
469 1056 public function cleanup_cache(): void {
470 1057 // WordPress handles transient cleanup automatically
471 1058 // This method is for future custom cache cleanup if needed
472 - }
473 -
474 - // ========================================
475 - // SEO Intelligence Enhancement Methods
476 - // ========================================
477 -
478 - /**
479 - * Get intelligent dashboard data with trends and insights
480 - *
481 - * @param string $date_range Date range for analysis
482 - * @return array Enhanced dashboard data with intelligence
483 - */
484 - public function get_intelligent_dashboard_data(string $date_range = '30d'): array {
485 - // Get base dashboard data
486 - $dashboard_data = $this->get_dashboard_data($date_range);
487 -
488 - // Check if there's an error in the data
489 - if (isset($dashboard_data['error'])) {
490 - return [
491 - 'success' => false,
492 - 'data' => null,
493 - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'],
494 - 'timestamp' => current_time('mysql')
495 - ];
496 - }
497 -
498 - // Check if we have real data available
499 - if (!$this->has_real_data($dashboard_data)) {
500 - return [
501 - 'success' => false,
502 - 'data' => null,
503 - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.',
504 - 'timestamp' => current_time('mysql')
505 - ];
506 - }
507 -
508 - // Initialize intelligence classes
509 - $trend_analyzer = new SEO_Trend_Analyzer();
510 - $scoring_engine = new SEO_Scoring_Engine();
511 - $insight_generator = new SEO_Insight_Generator();
512 -
513 - $data = $dashboard_data;
514 -
515 - // Generate trend analysis
516 - $current_data = $data;
517 - $historical_data = $this->get_historical_data($date_range);
518 -
519 - $trends = [
520 - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data),
521 - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range),
522 - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? [])
523 - ];
524 -
525 - // Calculate SEO health score
526 - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []);
527 -
528 - // Generate insights
529 - $insights = [
530 - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']),
531 - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']),
532 - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends'])
533 - ];
534 -
535 - // Combine all intelligence data
536 - $enhanced_data = array_merge($data, [
537 - 'intelligence' => [
538 - 'trends' => $trends,
539 - 'seo_health_score' => $seo_health,
540 - 'insights' => $insights,
541 - 'last_analyzed' => current_time('mysql')
542 - ]
543 - ]);
544 -
545 - return [
546 - 'success' => true,
547 - 'data' => $enhanced_data,
548 - 'message' => 'Intelligent dashboard data retrieved successfully'
549 - ];
550 - }
551 -
552 - /**
553 - * Get intelligent SEO opportunities with prioritization
554 - *
555 - * @param string $date_range Date range for analysis
556 - * @return array Enhanced opportunities with intelligence
557 - */
558 - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array {
559 - // Get base opportunities data
560 - $opportunities_data = $this->get_seo_opportunities($date_range);
561 -
562 - // Check if there's an error in the data
563 - if (isset($opportunities_data['error'])) {
564 - return [
565 - 'success' => false,
566 - 'data' => null,
567 - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'],
568 - 'timestamp' => current_time('mysql')
569 - ];
570 - }
571 -
572 - // Check if we have real search console data for opportunities
573 - $search_performance = $opportunities_data['search_performance'] ?? [];
574 - $has_search_data = !empty($search_performance['rows']) ||
575 - ($search_performance['total_clicks'] ?? 0) > 0 ||
576 - ($search_performance['total_impressions'] ?? 0) > 0;
577 -
578 - if (!$has_search_data) {
579 - return [
580 - 'success' => false,
581 - 'data' => null,
582 - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.',
583 - 'timestamp' => current_time('mysql')
584 - ];
585 - }
586 -
587 - // Initialize intelligence classes
588 - $opportunity_detector = new SEO_Opportunity_Detector();
589 - $scoring_engine = new SEO_Scoring_Engine();
590 -
591 - $data = $opportunities_data;
592 -
593 - // Detect intelligent opportunities
594 - $search_console_data = $data['search_performance'] ?? [];
595 - $analytics_data = $data;
596 -
597 - $intelligent_opportunities = [
598 - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data),
599 - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data),
600 - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data)
601 - ];
602 -
603 - // Prioritize all opportunities
604 - $all_opportunities = array_merge(
605 - $intelligent_opportunities['quick_wins']['opportunities'] ?? [],
606 - $intelligent_opportunities['content_opportunities']['opportunities'] ?? [],
607 - $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? []
608 - );
609 -
610 - $prioritized = $opportunity_detector->prioritize_opportunities($all_opportunities);
611 - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities);
612 -
613 - // Enhance original data with intelligence
614 - $enhanced_data = array_merge($data, [
615 - 'intelligent_opportunities' => $intelligent_opportunities,
616 - 'prioritized_opportunities' => $prioritized,
617 - 'impact_effort_matrix' => $impact_matrix,
618 - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities),
619 - 'last_analyzed' => current_time('mysql')
620 - ]);
621 -
622 - return [
623 - 'success' => true,
624 - 'data' => $enhanced_data,
625 - 'message' => 'Intelligent SEO opportunities retrieved successfully'
626 - ];
627 - }
628 -
629 - /**
630 - * Get SEO performance insights
631 - *
632 - * @param string $date_range Date range for analysis
633 - * @return array SEO insights data
634 - */
635 - public function get_seo_insights(string $date_range = '30d'): array {
636 - $cache_key = "seo_insights_{$date_range}";
637 - $cached_data = get_transient($cache_key);
638 -
639 - if ($cached_data !== false) {
640 - return [
641 - 'success' => true,
642 - 'data' => $cached_data,
643 - 'cached' => true,
644 - 'message' => 'SEO insights retrieved from cache'
645 - ];
646 - }
647 -
648 - try {
649 - // Get dashboard data for analysis
650 - $dashboard_result = $this->get_intelligent_dashboard_data($date_range);
651 -
652 - if (!$dashboard_result['success']) {
653 - return $dashboard_result;
654 - }
655 -
656 - $dashboard_data = $dashboard_result['data'];
657 - $intelligence = $dashboard_data['intelligence'] ?? [];
658 -
659 - // Initialize insight generator
660 - $insight_generator = new SEO_Insight_Generator();
661 -
662 - // Collect all insights
663 - $all_insights = [];
664 -
665 - if (!empty($intelligence['insights']['traffic_insights']['insights'])) {
666 - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']);
667 - }
668 -
669 - if (!empty($intelligence['insights']['keyword_insights']['insights'])) {
670 - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']);
671 - }
672 -
673 - if (!empty($intelligence['insights']['content_insights']['insights'])) {
674 - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']);
675 - }
676 -
677 - // Format and prioritize insights
678 - $formatted_insights = $insight_generator->format_insights_for_display($all_insights);
679 - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights);
680 -
681 - $insights_data = [
682 - 'insights' => $prioritized_insights['prioritized_insights'],
683 - 'summary' => [
684 - 'total_insights' => count($formatted_insights),
685 - 'high_impact_count' => $prioritized_insights['high_impact_count'],
686 - 'action_required_count' => $prioritized_insights['action_required_count']
687 - ],
688 - 'seo_health_score' => $intelligence['seo_health_score'] ?? null,
689 - 'generated_at' => current_time('mysql')
690 - ];
691 -
692 - // Cache the results
693 - set_transient($cache_key, $insights_data, $this->cache_duration);
694 -
695 - return [
696 - 'success' => true,
697 - 'data' => $insights_data,
698 - 'cached' => false,
699 - 'message' => 'SEO insights generated successfully'
700 - ];
701 -
702 - } catch (Exception $e) {
703 - return [
704 - 'success' => false,
705 - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(),
706 - 'data' => null
707 - ];
708 - }
709 - }
710 -
711 - /**
712 - * Check if real analytics data is available
713 - *
714 - * @param array $dashboard_data Dashboard data to check
715 - * @return bool True if real data is available
716 - */
717 - private function has_real_data(array $dashboard_data): bool {
718 - // Check if we have meaningful traffic data
719 - $traffic = $dashboard_data['traffic'] ?? [];
720 - $search_performance = $dashboard_data['search_performance'] ?? [];
721 -
722 - $has_traffic = !empty($traffic) && (
723 - ($traffic['sessions'] ?? 0) > 0 ||
724 - ($traffic['pageviews'] ?? 0) > 0 ||
725 - ($traffic['active_users'] ?? 0) > 0
726 - );
727 -
728 - $has_search_data = !empty($search_performance) && (
729 - !empty($search_performance['rows']) ||
730 - ($search_performance['total_clicks'] ?? 0) > 0 ||
731 - ($search_performance['total_impressions'] ?? 0) > 0
732 - );
733 -
734 - return $has_traffic || $has_search_data;
735 - }
736 -
737 - /**
738 - * Get historical data for trend comparison
739 - *
740 - * @param string $current_range Current date range
741 - * @return array Historical data
742 - */
743 - private function get_historical_data(string $current_range): array {
744 - // Calculate previous period based on current range
745 - $previous_range = $this->calculate_previous_period($current_range);
746 -
747 - // Try to get actual historical data from previous period
748 - $historical_data = $this->get_dashboard_data($previous_range);
749 -
750 - // Return the actual historical data (may be empty if no real data available)
751 - return [
752 - 'sessions' => $historical_data['traffic']['sessions'] ?? 0,
753 - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0,
754 - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]],
755 - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0,
756 - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0
757 - ];
758 - }
759 -
760 - /**
761 - * Calculate previous period for comparison
762 - *
763 - * @param string $current_range Current range
764 - * @return string Previous period range
765 - */
766 - private function calculate_previous_period(string $current_range): string {
767 - // Simple mapping for now - could be enhanced with actual date calculations
768 - $period_mapping = [
769 - '7d' => '14d',
770 - '30d' => '60d',
771 - '90d' => '180d'
772 - ];
773 -
774 - return $period_mapping[$current_range] ?? '60d';
775 - }
776 -
777 - /**
778 - * Generate opportunity summary
779 - *
780 - * @param array $opportunities All opportunities
781 - * @return array Opportunity summary
782 - */
783 - private function generate_opportunity_summary(array $opportunities): array {
784 - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []);
785 - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []);
786 - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []);
787 -
788 - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count;
789 -
790 - $potential_clicks = 0;
791 - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) {
792 - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks'];
793 - }
794 -
795 - return [
796 - 'total_opportunities' => $total_opportunities,
797 - 'quick_wins_count' => $quick_wins_count,
798 - 'content_opportunities_count' => $content_opportunities_count,
799 - 'keyword_opportunities_count' => $keyword_opportunities_count,
800 - 'potential_additional_clicks' => $potential_clicks,
801 - 'priority_recommendation' => $quick_wins_count > 0 ?
802 - 'Focus on quick wins first for immediate impact' :
803 - 'Focus on content optimization for long-term growth'
804 - ];
805 - }
806 -
807 - /**
808 - * Clear intelligence cache
809 - *
810 - * @return array Clear result
811 - */
812 - public function clear_intelligence_cache(): array {
813 - $intelligence_cache_keys = [
814 - 'seo_insights_7d',
815 - 'seo_insights_30d',
816 - 'seo_insights_90d',
817 - 'intelligent_dashboard_7d',
818 - 'intelligent_dashboard_30d',
819 - 'intelligent_dashboard_90d',
820 - 'intelligent_opportunities_7d',
821 - 'intelligent_opportunities_30d',
822 - 'intelligent_opportunities_90d'
823 - ];
824 -
825 - $cleared = 0;
826 - foreach ($intelligence_cache_keys as $key) {
827 - if (delete_transient($key)) {
828 - $cleared++;
829 - }
830 - }
831 -
832 - return [
833 - 'success' => true,
834 - 'message' => "Cleared {$cleared} intelligence cache entries",
835 - 'cleared_count' => $cleared,
836 - 'timestamp' => current_time('mysql')
837 - ];
838 1059 }
839 1060 }