PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.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 All 56 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.14.1, at includes/seo/class-analytics-manager.php

1,179 lines 43.0 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 // Position distribution over every query with an
755 // impression, not over the 1,000-row list above (#913).
756 $position_distribution = $this->search_analytics_client
757 ? $this->count_position_distribution($site_url, $start_date, $end_date, $search_performance['rows'] ?? [])
758 : self::bucket_positions(
759 $search_performance['rows'] ?? [],
760 count($search_performance['rows'] ?? []) < 1000
761 );
762
763 $dashboard_data['search_performance'] = array_merge($search_performance, [
764 'totals' => $totals,
765 'position_distribution' => $position_distribution
766 ]);
767 } // Closing Search Console block
768
769 // If successful, break loop
770 break;
771 } catch (\Exception $e) {
772 // Check for 401 error
773 if ($e->getCode() === 401 && $retry_count < $max_retries) {
774 $this->refresh_access_token(true); // Force refresh
775
776 // Re-initialize clients with new token
777 $this->initialize_clients();
778
779 $retry_count++;
780 continue;
781 }
782 $dashboard_data['error'] = $e->getMessage();
783 break;
784 }
785 }
786
787 // Add last updated timestamp
788 $dashboard_data['last_updated'] = current_time('mysql');
789
790 // Cache the results — but never cache an error payload, otherwise a
791 // transient failure (e.g. a Google API 401) would be served from the
792 // cache for the full TTL even after the underlying issue is fixed.
793 // Core Web Vitals are deliberately NOT part of this cache (see below).
794 if (empty($dashboard_data['error'])) {
795 set_transient($cache_key, $dashboard_data, $this->cache_duration);
796 }
797
798 // Merge Core Web Vitals from their own short-lived cache after the
799 // long-lived GSC payload has been stored.
800 $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
801
802 return $dashboard_data;
803 }
804
805 /**
806 * Rows per page when counting the position distribution. The most the
807 * Search Analytics API returns in one request.
808 *
809 * @since 2.15.0
810 */
811 private const POSITION_PAGE_SIZE = 25000;
812
813 /**
814 * Pages read before the count stops: 200,000 queries. A property past
815 * that is counted over its top 200,000 by clicks and flagged incomplete.
816 *
817 * @since 2.15.0
818 */
819 private const POSITION_MAX_PAGES = 8;
820
821 /**
822 * Count the period's queries into position buckets across the whole
823 * property (#913).
824 *
825 * The dashboard's query list is capped at 1,000 rows ordered by clicks,
826 * so counting it told any larger site it had exactly 1,000 queries and
827 * dropped the long tail, which is where positions 51-100 live. When that
828 * list came back short it already holds every query and is counted as
829 * is, with no extra request. Otherwise the property is paged with
830 * `startRow` at POSITION_PAGE_SIZE rows until a short page, counting as
831 * rows arrive rather than keeping them.
832 *
833 * A page that fails (other than a 401, which is re-thrown so the token
834 * refresh runs) leaves the count at what was read so far, flagged
835 * `complete: false`, rather than failing the whole dashboard.
836 *
837 * @since 2.15.0
838 *
839 * @param string $site_url Search Console property.
840 * @param string $start_date Window start (Y-m-d).
841 * @param string $end_date Window end (Y-m-d).
842 * @param array $first_rows The capped query list already fetched.
843 * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
844 * @throws \Exception On a 401, so get_dashboard_data() can refresh the token.
845 */
846 private function count_position_distribution(string $site_url, string $start_date, string $end_date, array $first_rows): array {
847 if (count($first_rows) < 1000) {
848 return self::bucket_positions($first_rows, true);
849 }
850
851 $distribution = self::bucket_positions([], true);
852 $start_row = 0;
853
854 for ($page = 0; $page < self::POSITION_MAX_PAGES; $page++) {
855 try {
856 $rows = $this->search_analytics_client->get_search_analytics_data(
857 $site_url,
858 $start_date,
859 $end_date,
860 ['query'],
861 self::POSITION_PAGE_SIZE,
862 $start_row
863 )['rows'] ?? [];
864 } catch (\Exception $e) {
865 if ($e->getCode() === 401) {
866 throw $e;
867 }
868 // Nothing read yet: the capped list is the best there is.
869 $partial = $start_row === 0 ? self::bucket_positions($first_rows, false) : $distribution;
870 $partial['complete'] = false;
871 return $partial;
872 }
873
874 $page_counts = self::bucket_positions($rows, true);
875 foreach (['top_3', '4_10', '10_50', '51_100', 'over_100'] as $bucket) {
876 $distribution[$bucket] += $page_counts[$bucket];
877 }
878
879 if (count($rows) < self::POSITION_PAGE_SIZE) {
880 return $distribution;
881 }
882 $start_row += self::POSITION_PAGE_SIZE;
883 }
884
885 $distribution['complete'] = false;
886 return $distribution;
887 }
888
889 /**
890 * Bucket Search Console rows by average position.
891 *
892 * `10_50` is the historical key for positions 11-50. Rows past 100 are
893 * counted in `over_100`: they still had impressions.
894 *
895 * @since 2.15.0
896 *
897 * @param array $rows Search Console rows.
898 * @param bool $complete Whether $rows is every query in the window.
899 * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
900 */
901 private static function bucket_positions(array $rows, bool $complete): array {
902 $distribution = [
903 'top_3' => 0,
904 '4_10' => 0,
905 '10_50' => 0,
906 '51_100' => 0,
907 'over_100' => 0,
908 'complete' => $complete,
909 ];
910
911 foreach ($rows as $row) {
912 $position = (float) ($row['position'] ?? 0);
913 if ($position <= 3) {
914 $distribution['top_3']++;
915 } elseif ($position <= 10) {
916 $distribution['4_10']++;
917 } elseif ($position <= 50) {
918 $distribution['10_50']++;
919 } elseif ($position <= 100) {
920 $distribution['51_100']++;
921 } else {
922 $distribution['over_100']++;
923 }
924 }
925
926 return $distribution;
927 }
928
929 /**
930 * Get Core Web Vitals for the analytics dashboard, cached independently
931 * of the dashboard payload.
932 *
933 * Successful results are cached for 1 hour; failures are never cached
934 * here (the PageSpeed client itself remembers failures for a few minutes
935 * to avoid re-blocking requests on a broken URL), so CWV recovers as soon
936 * as PageSpeed does instead of staying empty for the dashboard cache's
937 * 1-3 day TTL.
938 *
939 * @return array Core Web Vitals data, or an error payload
940 */
941 private function get_dashboard_core_web_vitals(): array {
942 $cached = get_transient('thinkrank_dashboard_cwv');
943 if (is_array($cached)) {
944 return $cached;
945 }
946
947 if (!$this->pagespeed_client) {
948 $this->initialize_clients();
949 }
950
951 if (!$this->pagespeed_client) {
952 return [];
953 }
954
955 try {
956 $core_web_vitals = $this->pagespeed_client->get_core_web_vitals(get_site_url());
957 set_transient('thinkrank_dashboard_cwv', $core_web_vitals, HOUR_IN_SECONDS);
958 return $core_web_vitals;
959 } catch (\Exception $psi_error) {
960 return [
961 'error' => $psi_error->getMessage(),
962 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
963 ];
964 }
965 }
966
967 /**
968 * Get SEO opportunities using Search Console data
969 *
970 * @param string $date_range Date range for analysis
971 * @return array SEO opportunities
972 */
973 public function get_seo_opportunities(string $date_range = '30d'): array {
974 $cache_key = "seo_opportunities_{$date_range}";
975 $cached_data = get_transient($cache_key);
976
977 if ($cached_data !== false) {
978 return $cached_data;
979 }
980
981 $opportunities = [
982 'keyword_opportunities' => [],
983 'page_opportunities' => [],
984 'device_insights' => [],
985 'last_updated' => current_time('mysql')
986 ];
987
988 $retry_count = 0;
989 $max_retries = 1;
990
991 while ($retry_count <= $max_retries) {
992 try {
993 if ($this->search_console_client) {
994 $site_url = $this->get_setting('search_console_property', get_site_url());
995
996 // Get keyword opportunities
997 $opportunities['keyword_opportunities'] = $this->search_console_client->get_keyword_opportunities($site_url, $date_range);
998
999 // Get device performance insights
1000 $opportunities['device_insights'] = $this->search_console_client->get_device_performance($site_url, $date_range);
1001
1002 // Get search appearance data
1003 $opportunities['search_appearance'] = $this->search_console_client->get_search_appearance($site_url, $date_range);
1004 }
1005
1006 // If successful, break loop
1007 break;
1008 } catch (\Exception $e) {
1009 // Check for 401 error
1010 if ($e->getCode() === 401 && $retry_count < $max_retries) {
1011 $this->refresh_access_token(true); // Force refresh
1012
1013 // Re-initialize clients with new token
1014 $this->initialize_clients();
1015
1016 $retry_count++;
1017 continue;
1018 }
1019
1020 $opportunities['error'] = $e->getMessage();
1021 break;
1022 }
1023 }
1024
1025 // Cache the results — but never cache an error payload (see
1026 // get_dashboard_data() for rationale).
1027 if (empty($opportunities['error'])) {
1028 set_transient($cache_key, $opportunities, $this->cache_duration);
1029 }
1030
1031 return $opportunities;
1032 }
1033
1034 /**
1035 * Memoized merge of the two settings categories this manager reads from.
1036 * Rebuilt when settings are updated through update_settings() below.
1037 *
1038 * @var array|null
1039 */
1040 private ?array $merged_settings = null;
1041
1042 private function get_setting(string $key, $fallback = '') {
1043 if ($this->merged_settings === null) {
1044 // Merge settings to allow access to both categories. Memoized:
1045 // this getter is called many times per request and each category
1046 // read decrypts every sensitive option again.
1047 $this->merged_settings = array_merge(
1048 $this->settings_manager->get_settings('integrations'),
1049 $this->settings_manager->get_settings('seo_analytics')
1050 );
1051 }
1052
1053 return $this->merged_settings[$key] ?? $fallback;
1054 }
1055
1056 /**
1057 * One-click setup for Google Search Console verification
1058 * Following ThinkRank setup patterns
1059 *
1060 * @param string $site_url Site URL to verify
1061 * @return array Setup results
1062 */
1063 public function setup_search_console_verification(string $site_url): array {
1064 try {
1065 if (!$this->search_console_client) {
1066 return [
1067 'success' => false,
1068 'message' => 'Search Console API key not configured'
1069 ];
1070 }
1071
1072 $verification_result = $this->search_console_client->verify_site($site_url);
1073
1074 if ($verification_result['success']) {
1075 // Update settings with verified site URL
1076 $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
1077 $this->merged_settings = null;
1078 }
1079
1080 return $verification_result;
1081 } catch (\Exception $e) {
1082 return [
1083 'success' => false,
1084 'message' => $e->getMessage()
1085 ];
1086 }
1087 }
1088
1089 /**
1090 * Force refresh of all cached data
1091 *
1092 * @return array Refresh results
1093 */
1094 public function refresh_data(): array {
1095 // Clear all analytics-related transients, including the previous-period
1096 // ranges used for trend comparison (14d/60d/180d) and the separately
1097 // cached Core Web Vitals payload.
1098 $cache_keys = [
1099 'analytics_dashboard_v5_7d',
1100 'analytics_dashboard_v5_30d',
1101 'analytics_dashboard_v5_90d',
1102 'analytics_dashboard_v5_14d',
1103 'analytics_dashboard_v5_60d',
1104 'analytics_dashboard_v5_180d',
1105 'seo_opportunities_7d',
1106 'seo_opportunities_30d',
1107 'seo_opportunities_90d',
1108 'indexing_status',
1109 'thinkrank_dashboard_cwv'
1110 ];
1111
1112 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
1113 // URL + device, so compute them for the URL/device combinations the
1114 // plugin actually tests.
1115 foreach (array_unique([home_url(), get_site_url()]) as $url) {
1116 foreach (['mobile', 'desktop'] as $device) {
1117 $psi_hash = md5($url . '|' . $device);
1118 $legacy_hash = md5($url . '_' . $device);
1119 $cache_keys[] = 'thinkrank_psi_snapshot_' . $psi_hash;
1120 $cache_keys[] = 'thinkrank_psi_failure_' . $psi_hash;
1121 $cache_keys[] = 'thinkrank_core_web_vitals_' . $legacy_hash;
1122 $cache_keys[] = 'thinkrank_opportunities_' . $legacy_hash;
1123 $cache_keys[] = 'thinkrank_diagnostics_' . $legacy_hash;
1124 }
1125 }
1126
1127 $cleared = 0;
1128 foreach ($cache_keys as $key) {
1129 if (delete_transient($key)) {
1130 $cleared++;
1131 }
1132 }
1133
1134 return [
1135 'success' => true,
1136 'message' => "Cleared {$cleared} cached data entries",
1137 'cleared_count' => $cleared,
1138 'timestamp' => current_time('mysql')
1139 ];
1140 }
1141
1142 /**
1143 * Get client status for debugging
1144 *
1145 * @return array Client status information
1146 */
1147 public function get_client_status(): array {
1148 return [
1149 'google_analytics' => [
1150 'initialized' => !is_null($this->analytics_client),
1151 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
1152 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
1153 ],
1154 'search_console' => [
1155 'initialized' => !is_null($this->search_console_client),
1156 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
1157 'site_url_configured' => !empty($this->get_setting('search_console_property'))
1158 ],
1159 'pagespeed' => [
1160 'initialized' => !is_null($this->pagespeed_client),
1161 'api_key_configured' => !empty($this->get_setting('google_pagespeed_api_key'))
1162 ],
1163 'cache_duration' => $this->cache_duration,
1164 'last_checked' => current_time('mysql')
1165 ];
1166 }
1167
1168 /**
1169 * Cleanup expired cache data
1170 * Following ThinkRank cache cleanup patterns
1171 *
1172 * @return void
1173 */
1174 public function cleanup_cache(): void {
1175 // WordPress handles transient cleanup automatically
1176 // This method is for future custom cache cleanup if needed
1177 }
1178 }
1179