PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 releases
thinkrank / includes / seo / class-analytics-manager.php

class-analytics-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/seo/class-analytics-manager.php

1,061 lines 38.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Analytics Manager Class
5 *
6 * Coordinates Google API integrations for SEO analytics data collection,
7 * processing, and AI-powered insights generation. Manages Google Analytics,
8 * Search Console, and PageSpeed data with intelligent caching and rate limiting.
9 *
10 * @package ThinkRank
11 * @subpackage SEO
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO;
18
19 use ThinkRank\Core\Settings_Manager;
20 use ThinkRank\Integrations\Google_Analytics_Client;
21 use ThinkRank\Integrations\Google_Search_Console_Client;
22 use ThinkRank\Integrations\Google_PageSpeed_Client;
23 use ThinkRank\Integrations\Google_Search_Analytics_Client;
24 use ThinkRank\Integrations\Google_OAuth_Proxy;
25
26 // Prevent direct access
27 if (!defined('ABSPATH')) {
28 exit;
29 }
30
31 /**
32 * Analytics Manager Class
33 *
34 * Single Responsibility: Coordinate Google API data collection and processing
35 * Following ThinkRank manager patterns from AI_Manager and Performance_Monitoring_Manager
36 *
37 * @since 1.0.0
38 */
39 class Analytics_Manager {
40
41 /**
42 * Settings Manager instance
43 *
44 * @var Settings_Manager
45 */
46 private Settings_Manager $settings_manager;
47
48 /**
49 * Google Analytics client
50 *
51 * @var Google_Analytics_Client|null
52 */
53 private ?Google_Analytics_Client $analytics_client = null;
54
55 /**
56 * Google Search Console client
57 *
58 * @var Google_Search_Console_Client|null
59 */
60 private ?Google_Search_Console_Client $search_console_client = null;
61
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 /**
70 * Google PageSpeed client
71 *
72 * @var Google_PageSpeed_Client|null
73 */
74 private ?Google_PageSpeed_Client $pagespeed_client = null;
75
76 /**
77 * Cache duration in seconds
78 *
79 * @var int
80 */
81 private int $cache_duration;
82
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 /**
128 * Constructor
129 *
130 * @param Settings_Manager|null $settings_manager Settings manager instance
131 */
132 public function __construct(?Settings_Manager $settings_manager = null) {
133 $this->settings_manager = $settings_manager ?? new Settings_Manager();
134 // Pro: daily refresh (86400s), Free: 3-day refresh (259200s)
135 $this->cache_duration = defined('THINKRANK_PRO_VERSION') ? 86400 : 259200;
136 }
137
138 /**
139 * Initialize Analytics Manager
140 * Following ThinkRank init patterns
141 *
142 * @return void
143 */
144 public function init(): void {
145 // Register custom cron interval (45 minutes)
146 add_filter('cron_schedules', [$this, 'add_cron_intervals']);
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
158 // Schedule cache cleanup
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']);
163 }
164
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 /**
253 * Initialize Google API clients
254 * Following AI_Manager client initialization pattern
255 *
256 * @return void
257 */
258 public function initialize_clients(): void {
259 try {
260 // Refresh token if needed (non-forced, checks expiration)
261 $this->refresh_access_token();
262
263 // Initialize Search Console client
264 $gsc_api_key = $this->get_setting('google_search_console_api_key');
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);
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 }
307
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');
355 }
356 $this->merged_settings = null;
357 }
358 }
359
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 }
389 }
390
391 if (!$next) {
392 wp_schedule_event(time(), 'thinkrank_45min', 'thinkrank_google_token_refresh');
393 }
394 }
395
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 /**
599 * Test all Google API connections
600 * Following ThinkRank test_connection patterns
601 *
602 * @return array Connection test results
603 */
604 public function test_connections(): array {
605 $results = [
606 'google_analytics' => ['status' => 'not_configured'],
607 'search_console' => ['status' => 'not_configured'],
608 'pagespeed' => ['status' => 'not_configured']
609 ];
610
611 // Test Google Analytics connection
612 if ($this->analytics_client) {
613 try {
614 $test_result = $this->analytics_client->test_connection();
615 $results['google_analytics'] = [
616 'status' => $test_result['success'] ? 'connected' : 'error',
617 'message' => $test_result['message'],
618 'details' => $test_result
619 ];
620 } catch (\Exception $e) {
621 $results['google_analytics'] = [
622 'status' => 'error',
623 'message' => $e->getMessage()
624 ];
625 }
626 }
627
628 // Test Search Console connection
629 if ($this->search_console_client) {
630 try {
631 $test_result = $this->search_console_client->test_connection();
632 $results['search_console'] = [
633 'status' => $test_result['success'] ? 'connected' : 'error',
634 'message' => $test_result['message'],
635 'details' => $test_result
636 ];
637 } catch (\Exception $e) {
638 $results['search_console'] = [
639 'status' => 'error',
640 'message' => $e->getMessage()
641 ];
642 }
643 }
644
645 // Test PageSpeed connection
646 if ($this->pagespeed_client) {
647 try {
648 $test_result = $this->pagespeed_client->test_connection();
649 $results['pagespeed'] = [
650 'status' => $test_result['success'] ? 'connected' : 'error',
651 'message' => $test_result['message'],
652 'details' => $test_result
653 ];
654 } catch (\Exception $e) {
655 $results['pagespeed'] = [
656 'status' => 'error',
657 'message' => $e->getMessage()
658 ];
659 }
660 }
661
662 return $results;
663 }
664
665 /**
666 * Get analytics dashboard data
667 * Combines data from all Google APIs with caching
668 *
669 * @param string $date_range Date range for data
670 * @return array Dashboard data
671 *
672 * @throws \Exception On failure.
673 */
674 public function get_dashboard_data(string $date_range = '30d'): array {
675 $cache_key = "analytics_dashboard_v5_{$date_range}";
676 $cached_data = get_transient($cache_key);
677
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();
684 return $cached_data;
685 }
686
687 $dashboard_data = [
688 'traffic' => [],
689 'search_performance' => [],
690 'core_web_vitals' => [],
691 'last_updated' => current_time('mysql'),
692 'date_range' => $date_range
693 ];
694
695 $retry_count = 0;
696 $max_retries = 1;
697
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;
790 }
791 }
792
793 // Add last updated timestamp
794 $dashboard_data['last_updated'] = current_time('mysql');
795
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);
802 }
803
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();
807
808 return $dashboard_data;
809 }
810
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 /**
850 * Get SEO opportunities using Search Console data
851 *
852 * @param string $date_range Date range for analysis
853 * @return array SEO opportunities
854 */
855 public function get_seo_opportunities(string $date_range = '30d'): array {
856 $cache_key = "seo_opportunities_{$date_range}";
857 $cached_data = get_transient($cache_key);
858
859 if ($cached_data !== false) {
860 return $cached_data;
861 }
862
863 $opportunities = [
864 'keyword_opportunities' => [],
865 'page_opportunities' => [],
866 'device_insights' => [],
867 'last_updated' => current_time('mysql')
868 ];
869
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;
904 }
905 }
906
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);
911 }
912
913 return $opportunities;
914 }
915
916 /**
917 * Memoized merge of the two settings categories this manager reads from.
918 * Rebuilt when settings are updated through update_settings() below.
919 *
920 * @var array|null
921 */
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;
936 }
937
938 /**
939 * One-click setup for Google Search Console verification
940 * Following ThinkRank setup patterns
941 *
942 * @param string $site_url Site URL to verify
943 * @return array Setup results
944 */
945 public function setup_search_console_verification(string $site_url): array {
946 try {
947 if (!$this->search_console_client) {
948 return [
949 'success' => false,
950 'message' => 'Search Console API key not configured'
951 ];
952 }
953
954 $verification_result = $this->search_console_client->verify_site($site_url);
955
956 if ($verification_result['success']) {
957 // Update settings with verified site URL
958 $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
959 $this->merged_settings = null;
960 }
961
962 return $verification_result;
963 } catch (\Exception $e) {
964 return [
965 'success' => false,
966 'message' => $e->getMessage()
967 ];
968 }
969 }
970
971 /**
972 * Force refresh of all cached data
973 *
974 * @return array Refresh results
975 */
976 public function refresh_data(): array {
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.
980 $cache_keys = [
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',
987 'seo_opportunities_7d',
988 'seo_opportunities_30d',
989 'seo_opportunities_90d',
990 'indexing_status',
991 'thinkrank_dashboard_cwv'
992 ];
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
1009 $cleared = 0;
1010 foreach ($cache_keys as $key) {
1011 if (delete_transient($key)) {
1012 $cleared++;
1013 }
1014 }
1015
1016 return [
1017 'success' => true,
1018 'message' => "Cleared {$cleared} cached data entries",
1019 'cleared_count' => $cleared,
1020 'timestamp' => current_time('mysql')
1021 ];
1022 }
1023
1024 /**
1025 * Get client status for debugging
1026 *
1027 * @return array Client status information
1028 */
1029 public function get_client_status(): array {
1030 return [
1031 'google_analytics' => [
1032 'initialized' => !is_null($this->analytics_client),
1033 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
1034 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
1035 ],
1036 'search_console' => [
1037 'initialized' => !is_null($this->search_console_client),
1038 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
1039 'site_url_configured' => !empty($this->get_setting('search_console_property'))
1040 ],
1041 'pagespeed' => [
1042 'initialized' => !is_null($this->pagespeed_client),
1043 'api_key_configured' => !empty($this->get_setting('google_pagespeed_api_key'))
1044 ],
1045 'cache_duration' => $this->cache_duration,
1046 'last_checked' => current_time('mysql')
1047 ];
1048 }
1049
1050 /**
1051 * Cleanup expired cache data
1052 * Following ThinkRank cache cleanup patterns
1053 *
1054 * @return void
1055 */
1056 public function cleanup_cache(): void {
1057 // WordPress handles transient cleanup automatically
1058 // This method is for future custom cache cleanup if needed
1059 }
1060 }
1061