PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 53 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 2.12.0, at includes/seo/class-analytics-manager.php

1,067 lines 38.7 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 // Every entry carries a `message`, configured or not: the result is
606 // handed to the Integrations screen as JSON, and a key that exists on
607 // some services and not others reads as `undefined` there.
608 $results = [
609 'google_analytics' => ['status' => 'not_configured', 'message' => ''],
610 'search_console' => ['status' => 'not_configured', 'message' => ''],
611 'pagespeed' => ['status' => 'not_configured', 'message' => '']
612 ];
613
614 // One shape for all three. They were three copies of the same block
615 // differing only in the client, which is how the same unguarded read
616 // came to exist in triplicate (#852).
617 $clients = [
618 'google_analytics' => $this->analytics_client,
619 'search_console' => $this->search_console_client,
620 'pagespeed' => $this->pagespeed_client,
621 ];
622
623 foreach ($clients as $service => $client) {
624 if (!$client) {
625 continue;
626 }
627
628 $results[$service] = $this->describe_connection_test($client);
629 }
630
631 return $results;
632 }
633
634 /**
635 * Run one client's connection test and report it in a fixed shape.
636 *
637 * The clients answer success with `message` and failure with `error`, and
638 * this read `$test_result['message']` unconditionally — so a failed test
639 * raised "Undefined array key" and handed the admin an error with
640 * `message => null`. The reason the client had in hand was the one thing
641 * the screen needed. The clients now set `message` on both branches; the
642 * fallbacks here cover a client that does not, including one that answers
643 * with neither key.
644 *
645 * @since 2.12.0
646 *
647 * @param object $client A client exposing test_connection(): array.
648 * @return array{status:string, message:string, details?:array}
649 */
650 private function describe_connection_test($client): array {
651 try {
652 $test_result = $client->test_connection();
653
654 if (!is_array($test_result)) {
655 return ['status' => 'error', 'message' => ''];
656 }
657
658 return [
659 'status' => !empty($test_result['success']) ? 'connected' : 'error',
660 'message' => (string) ($test_result['message'] ?? $test_result['error'] ?? ''),
661 'details' => $test_result,
662 ];
663 } catch (\Exception $e) {
664 return [
665 'status' => 'error',
666 'message' => $e->getMessage(),
667 ];
668 }
669 }
670
671 /**
672 * Get analytics dashboard data
673 * Combines data from all Google APIs with caching
674 *
675 * @param string $date_range Date range for data
676 * @return array Dashboard data
677 *
678 * @throws \Exception On failure.
679 */
680 public function get_dashboard_data(string $date_range = '30d'): array {
681 $cache_key = "analytics_dashboard_v5_{$date_range}";
682 $cached_data = get_transient($cache_key);
683
684 if ($cached_data !== false) {
685 // Core Web Vitals are cached separately with a much shorter
686 // lifetime than the GSC data (and failures are never cached), so
687 // a transient PageSpeed failure can't blank the CWV card for the
688 // dashboard cache's full 1-3 day TTL.
689 $cached_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
690 return $cached_data;
691 }
692
693 $dashboard_data = [
694 'traffic' => [],
695 'search_performance' => [],
696 'core_web_vitals' => [],
697 'last_updated' => current_time('mysql'),
698 'date_range' => $date_range
699 ];
700
701 $retry_count = 0;
702 $max_retries = 1;
703
704 while ($retry_count <= $max_retries) {
705 try {
706 // Ensure clients are initialized (lazy load) before any of
707 // them are used — this also builds the GA4 client when a
708 // property is configured.
709 if (!$this->search_console_client || !$this->search_analytics_client) {
710 $this->initialize_clients();
711 }
712
713 // Get Google Analytics traffic data. GA is optional — an
714 // isolated failure (misconfigured property, missing scope)
715 // must not abort the Search Console portion of the dashboard.
716 // 401s are re-thrown so the token-refresh retry below runs.
717 if ($this->analytics_client) {
718 try {
719 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
720 } catch (\Exception $ga_error) {
721 if ($ga_error->getCode() === 401) {
722 throw $ga_error;
723 }
724 $dashboard_data['traffic'] = [];
725 $dashboard_data['traffic_error'] = $ga_error->getMessage();
726 }
727 }
728
729 // Get Search Console data
730 if ($this->search_console_client) {
731 $site_url = $this->get_setting('search_console_property', get_site_url());
732 // Get totals
733 $totals = $this->search_console_client->get_search_totals($site_url, $date_range);
734
735 // Get performance data (keywords) using new client
736 if ($this->search_analytics_client) {
737 // GSC data has a 2-day delay; use D-2 as end_date to match the GSC dashboard.
738 $days = (int) str_replace('d', '', $date_range);
739 $end_date = gmdate('Y-m-d', strtotime('-2 days'));
740 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date)));
741
742 $search_performance = $this->search_analytics_client->get_search_analytics_data(
743 $site_url,
744 $start_date,
745 $end_date,
746 ['query'],
747 1000
748 );
749 } else {
750 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
751 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
752 }
753
754 // Calculate position distribution
755 $position_distribution = [
756 'top_3' => 0,
757 '4_10' => 0,
758 '10_50' => 0,
759 '51_100' => 0
760 ];
761
762 foreach ($search_performance['rows'] ?? [] as $row) {
763 $position = $row['position'] ?? 0;
764 if ($position <= 3) {
765 $position_distribution['top_3']++;
766 } elseif ($position <= 10) {
767 $position_distribution['4_10']++;
768 } elseif ($position <= 50) {
769 $position_distribution['10_50']++;
770 } elseif ($position <= 100) {
771 $position_distribution['51_100']++;
772 }
773 }
774
775 $dashboard_data['search_performance'] = array_merge($search_performance, [
776 'totals' => $totals,
777 'position_distribution' => $position_distribution
778 ]);
779 } // Closing Search Console block
780
781 // If successful, break loop
782 break;
783 } catch (\Exception $e) {
784 // Check for 401 error
785 if ($e->getCode() === 401 && $retry_count < $max_retries) {
786 $this->refresh_access_token(true); // Force refresh
787
788 // Re-initialize clients with new token
789 $this->initialize_clients();
790
791 $retry_count++;
792 continue;
793 }
794 $dashboard_data['error'] = $e->getMessage();
795 break;
796 }
797 }
798
799 // Add last updated timestamp
800 $dashboard_data['last_updated'] = current_time('mysql');
801
802 // Cache the results — but never cache an error payload, otherwise a
803 // transient failure (e.g. a Google API 401) would be served from the
804 // cache for the full TTL even after the underlying issue is fixed.
805 // Core Web Vitals are deliberately NOT part of this cache (see below).
806 if (empty($dashboard_data['error'])) {
807 set_transient($cache_key, $dashboard_data, $this->cache_duration);
808 }
809
810 // Merge Core Web Vitals from their own short-lived cache after the
811 // long-lived GSC payload has been stored.
812 $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
813
814 return $dashboard_data;
815 }
816
817 /**
818 * Get Core Web Vitals for the analytics dashboard, cached independently
819 * of the dashboard payload.
820 *
821 * Successful results are cached for 1 hour; failures are never cached
822 * here (the PageSpeed client itself remembers failures for a few minutes
823 * to avoid re-blocking requests on a broken URL), so CWV recovers as soon
824 * as PageSpeed does instead of staying empty for the dashboard cache's
825 * 1-3 day TTL.
826 *
827 * @return array Core Web Vitals data, or an error payload
828 */
829 private function get_dashboard_core_web_vitals(): array {
830 $cached = get_transient('thinkrank_dashboard_cwv');
831 if (is_array($cached)) {
832 return $cached;
833 }
834
835 if (!$this->pagespeed_client) {
836 $this->initialize_clients();
837 }
838
839 if (!$this->pagespeed_client) {
840 return [];
841 }
842
843 try {
844 $core_web_vitals = $this->pagespeed_client->get_core_web_vitals(get_site_url());
845 set_transient('thinkrank_dashboard_cwv', $core_web_vitals, HOUR_IN_SECONDS);
846 return $core_web_vitals;
847 } catch (\Exception $psi_error) {
848 return [
849 'error' => $psi_error->getMessage(),
850 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
851 ];
852 }
853 }
854
855 /**
856 * Get SEO opportunities using Search Console data
857 *
858 * @param string $date_range Date range for analysis
859 * @return array SEO opportunities
860 */
861 public function get_seo_opportunities(string $date_range = '30d'): array {
862 $cache_key = "seo_opportunities_{$date_range}";
863 $cached_data = get_transient($cache_key);
864
865 if ($cached_data !== false) {
866 return $cached_data;
867 }
868
869 $opportunities = [
870 'keyword_opportunities' => [],
871 'page_opportunities' => [],
872 'device_insights' => [],
873 'last_updated' => current_time('mysql')
874 ];
875
876 $retry_count = 0;
877 $max_retries = 1;
878
879 while ($retry_count <= $max_retries) {
880 try {
881 if ($this->search_console_client) {
882 $site_url = $this->get_setting('search_console_property', get_site_url());
883
884 // Get keyword opportunities
885 $opportunities['keyword_opportunities'] = $this->search_console_client->get_keyword_opportunities($site_url, $date_range);
886
887 // Get device performance insights
888 $opportunities['device_insights'] = $this->search_console_client->get_device_performance($site_url, $date_range);
889
890 // Get search appearance data
891 $opportunities['search_appearance'] = $this->search_console_client->get_search_appearance($site_url, $date_range);
892 }
893
894 // If successful, break loop
895 break;
896 } catch (\Exception $e) {
897 // Check for 401 error
898 if ($e->getCode() === 401 && $retry_count < $max_retries) {
899 $this->refresh_access_token(true); // Force refresh
900
901 // Re-initialize clients with new token
902 $this->initialize_clients();
903
904 $retry_count++;
905 continue;
906 }
907
908 $opportunities['error'] = $e->getMessage();
909 break;
910 }
911 }
912
913 // Cache the results — but never cache an error payload (see
914 // get_dashboard_data() for rationale).
915 if (empty($opportunities['error'])) {
916 set_transient($cache_key, $opportunities, $this->cache_duration);
917 }
918
919 return $opportunities;
920 }
921
922 /**
923 * Memoized merge of the two settings categories this manager reads from.
924 * Rebuilt when settings are updated through update_settings() below.
925 *
926 * @var array|null
927 */
928 private ?array $merged_settings = null;
929
930 private function get_setting(string $key, $fallback = '') {
931 if ($this->merged_settings === null) {
932 // Merge settings to allow access to both categories. Memoized:
933 // this getter is called many times per request and each category
934 // read decrypts every sensitive option again.
935 $this->merged_settings = array_merge(
936 $this->settings_manager->get_settings('integrations'),
937 $this->settings_manager->get_settings('seo_analytics')
938 );
939 }
940
941 return $this->merged_settings[$key] ?? $fallback;
942 }
943
944 /**
945 * One-click setup for Google Search Console verification
946 * Following ThinkRank setup patterns
947 *
948 * @param string $site_url Site URL to verify
949 * @return array Setup results
950 */
951 public function setup_search_console_verification(string $site_url): array {
952 try {
953 if (!$this->search_console_client) {
954 return [
955 'success' => false,
956 'message' => 'Search Console API key not configured'
957 ];
958 }
959
960 $verification_result = $this->search_console_client->verify_site($site_url);
961
962 if ($verification_result['success']) {
963 // Update settings with verified site URL
964 $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
965 $this->merged_settings = null;
966 }
967
968 return $verification_result;
969 } catch (\Exception $e) {
970 return [
971 'success' => false,
972 'message' => $e->getMessage()
973 ];
974 }
975 }
976
977 /**
978 * Force refresh of all cached data
979 *
980 * @return array Refresh results
981 */
982 public function refresh_data(): array {
983 // Clear all analytics-related transients, including the previous-period
984 // ranges used for trend comparison (14d/60d/180d) and the separately
985 // cached Core Web Vitals payload.
986 $cache_keys = [
987 'analytics_dashboard_v5_7d',
988 'analytics_dashboard_v5_30d',
989 'analytics_dashboard_v5_90d',
990 'analytics_dashboard_v5_14d',
991 'analytics_dashboard_v5_60d',
992 'analytics_dashboard_v5_180d',
993 'seo_opportunities_7d',
994 'seo_opportunities_30d',
995 'seo_opportunities_90d',
996 'indexing_status',
997 'thinkrank_dashboard_cwv'
998 ];
999
1000 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
1001 // URL + device, so compute them for the URL/device combinations the
1002 // plugin actually tests.
1003 foreach (array_unique([home_url(), get_site_url()]) as $url) {
1004 foreach (['mobile', 'desktop'] as $device) {
1005 $psi_hash = md5($url . '|' . $device);
1006 $legacy_hash = md5($url . '_' . $device);
1007 $cache_keys[] = 'thinkrank_psi_snapshot_' . $psi_hash;
1008 $cache_keys[] = 'thinkrank_psi_failure_' . $psi_hash;
1009 $cache_keys[] = 'thinkrank_core_web_vitals_' . $legacy_hash;
1010 $cache_keys[] = 'thinkrank_opportunities_' . $legacy_hash;
1011 $cache_keys[] = 'thinkrank_diagnostics_' . $legacy_hash;
1012 }
1013 }
1014
1015 $cleared = 0;
1016 foreach ($cache_keys as $key) {
1017 if (delete_transient($key)) {
1018 $cleared++;
1019 }
1020 }
1021
1022 return [
1023 'success' => true,
1024 'message' => "Cleared {$cleared} cached data entries",
1025 'cleared_count' => $cleared,
1026 'timestamp' => current_time('mysql')
1027 ];
1028 }
1029
1030 /**
1031 * Get client status for debugging
1032 *
1033 * @return array Client status information
1034 */
1035 public function get_client_status(): array {
1036 return [
1037 'google_analytics' => [
1038 'initialized' => !is_null($this->analytics_client),
1039 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
1040 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
1041 ],
1042 'search_console' => [
1043 'initialized' => !is_null($this->search_console_client),
1044 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
1045 'site_url_configured' => !empty($this->get_setting('search_console_property'))
1046 ],
1047 'pagespeed' => [
1048 'initialized' => !is_null($this->pagespeed_client),
1049 'api_key_configured' => !empty($this->get_setting('google_pagespeed_api_key'))
1050 ],
1051 'cache_duration' => $this->cache_duration,
1052 'last_checked' => current_time('mysql')
1053 ];
1054 }
1055
1056 /**
1057 * Cleanup expired cache data
1058 * Following ThinkRank cache cleanup patterns
1059 *
1060 * @return void
1061 */
1062 public function cleanup_cache(): void {
1063 // WordPress handles transient cleanup automatically
1064 // This method is for future custom cache cleanup if needed
1065 }
1066 }
1067