| @@ -16,9 +16,8 @@ | ||
| 16 | 16 | |
| 17 | 17 | namespace ThinkRank\SEO; |
| 18 | 18 | |
| 19 | 19 | use ThinkRank\Core\Settings_Manager; |
| 20 | -use ThinkRank\Core\Plan_Config; | |
| 21 | 20 | use ThinkRank\Integrations\Google_Analytics_Client; |
| 22 | 21 | use ThinkRank\Integrations\Google_Search_Console_Client; |
| 23 | 22 | use ThinkRank\Integrations\Google_PageSpeed_Client; |
| 24 | 23 | use ThinkRank\Integrations\Google_Search_Analytics_Client; |
| @@ -88,8 +87,82 @@ | ||
| 88 | 87 | */ |
| 89 | 88 | private static bool $token_refreshed_this_request = false; |
| 90 | 89 | |
| 91 | 90 | /** |
| 91 | + * Single-flight lock for the OAuth refresh exchange. | |
| 92 | + * | |
| 93 | + * @var string | |
| 94 | + */ | |
| 95 | + private const REFRESH_LOCK = 'thinkrank_token_refresh_lock'; | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * How long a held refresh lock stays valid. Longer than the request | |
| 99 | + * timeout below, so a request that dies mid-exchange still frees it. | |
| 100 | + * | |
| 101 | + * @var int | |
| 102 | + */ | |
| 103 | + private const REFRESH_LOCK_TTL = 60; | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * Set after a failed exchange; suppresses retries until it expires. | |
| 107 | + * | |
| 108 | + * @var string | |
| 109 | + */ | |
| 110 | + private const REFRESH_BACKOFF = 'thinkrank_token_refresh_backoff'; | |
| 111 | + | |
| 112 | + /** | |
| 113 | + * How long to stay quiet after a failed exchange. | |
| 114 | + * | |
| 115 | + * @var int | |
| 116 | + */ | |
| 117 | + private const REFRESH_BACKOFF_TTL = 300; | |
| 118 | + | |
| 119 | + /** | |
| 120 | + * Timeout for the refresh exchange. A healthy proxy answers in ~1s; the | |
| 121 | + * old 30s meant one outage held a request open for half a minute. | |
| 122 | + * | |
| 123 | + * @var int | |
| 124 | + */ | |
| 125 | + private const REFRESH_TIMEOUT = 10; | |
| 126 | + | |
| 127 | + /** | |
| 128 | + * Transient prefix for the cached dashboard payload, suffixed with the | |
| 129 | + * date range ("30d"). Carries the plugin prefix so uninstall's | |
| 130 | + * `_transient_thinkrank_%` sweep removes it; the payload holds up to | |
| 131 | + * 1,000 of the site's search queries (#918). | |
| 132 | + * | |
| 133 | + * @since 2.14.2 | |
| 134 | + * @var string | |
| 135 | + */ | |
| 136 | + public const DASHBOARD_CACHE_PREFIX = 'thinkrank_analytics_dashboard_v5_'; | |
| 137 | + | |
| 138 | + /** | |
| 139 | + * Transient prefix for the cached SEO opportunities payload, suffixed | |
| 140 | + * with the date range. Prefixed for the same reason as the dashboard's. | |
| 141 | + * | |
| 142 | + * @since 2.14.2 | |
| 143 | + * @var string | |
| 144 | + */ | |
| 145 | + public const OPPORTUNITIES_CACHE_PREFIX = 'thinkrank_seo_opportunities_'; | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * Every date range the dashboard cache is written under: the three the | |
| 149 | + * UI offers plus the doubled previous-period ranges used for trends. | |
| 150 | + * | |
| 151 | + * @since 2.14.2 | |
| 152 | + * @var string[] | |
| 153 | + */ | |
| 154 | + public const CACHED_DASHBOARD_RANGES = ['7d', '30d', '90d', '14d', '60d', '180d']; | |
| 155 | + | |
| 156 | + /** | |
| 157 | + * Every date range the opportunities cache is written under. | |
| 158 | + * | |
| 159 | + * @since 2.14.2 | |
| 160 | + * @var string[] | |
| 161 | + */ | |
| 162 | + public const CACHED_OPPORTUNITY_RANGES = ['7d', '30d', '90d']; | |
| 163 | + | |
| 164 | + /** | |
| 92 | 165 | * Constructor |
| 93 | 166 | * |
| 94 | 167 | * @param Settings_Manager|null $settings_manager Settings manager instance |
| 95 | 168 | */ |
| @@ -108,10 +181,11 @@ | ||
| 108 | 181 | public function init(): void { |
| 109 | 182 | // Register custom cron interval (45 minutes) |
| 110 | 183 | add_filter('cron_schedules', [$this, 'add_cron_intervals']); |
| 111 | 184 | |
| 112 | - // Initialize Google API clients | |
| 113 | - add_action('init', [$this, 'initialize_clients']); | |
| 185 | + // Initialize Google API clients — but only in the contexts that can use | |
| 186 | + // them. See maybe_initialize_clients(). | |
| 187 | + add_action('init', [$this, 'maybe_initialize_clients']); | |
| 114 | 188 | |
| 115 | 189 | // Initialize token refresh scheduling |
| 116 | 190 | add_action('init', [$this, 'init_token_refresh']); |
| 117 | 191 | |
| @@ -177,8 +251,43 @@ | ||
| 177 | 251 | return $this->get_setting('search_console_property', get_site_url()); |
| 178 | 252 | } |
| 179 | 253 | |
| 180 | 254 | /** |
| 255 | + * Initialize the Google clients on `init`, in the contexts that use them. | |
| 256 | + * | |
| 257 | + * initialize_clients() refreshes the OAuth token, which is a blocking | |
| 258 | + * outbound POST to the OAuth proxy. Hooked unconditionally it ran on every | |
| 259 | + * anonymous front-end request, so a proxy outage became a site-wide TTFB | |
| 260 | + * collapse — with each visitor waiting for the network call, and none of | |
| 261 | + * them able to use a Google client anyway. No front-end code path reads | |
| 262 | + * one: every consumer is a REST endpoint, a cron callback or WP-CLI, and | |
| 263 | + * each either calls initialize_clients() itself or goes through | |
| 264 | + * get_search_console_client(), which initializes lazily (#383). | |
| 265 | + * | |
| 266 | + * @since 2.0.1 | |
| 267 | + * @return void | |
| 268 | + */ | |
| 269 | + public function maybe_initialize_clients(): void { | |
| 270 | + $wanted = is_admin() | |
| 271 | + || wp_doing_cron() | |
| 272 | + || (defined('REST_REQUEST') && REST_REQUEST) | |
| 273 | + || (defined('WP_CLI') && WP_CLI); | |
| 274 | + | |
| 275 | + /** | |
| 276 | + * Filter whether the Google API clients are initialized for this request. | |
| 277 | + * | |
| 278 | + * @since 2.0.1 | |
| 279 | + * | |
| 280 | + * @param bool $wanted Whether to initialize the clients. | |
| 281 | + */ | |
| 282 | + if (!apply_filters('thinkrank_initialize_google_clients', $wanted)) { | |
| 283 | + return; | |
| 284 | + } | |
| 285 | + | |
| 286 | + $this->initialize_clients(); | |
| 287 | + } | |
| 288 | + | |
| 289 | + /** | |
| 181 | 290 | * Initialize Google API clients |
| 182 | 291 | * Following AI_Manager client initialization pattern |
| 183 | 292 | * |
| 184 | 293 | * @return void |
| @@ -375,10 +484,30 @@ | ||
| 375 | 484 | // Calculate absolute expiration time (created + relative seconds) |
| 376 | 485 | $expiration_time = $created + $expires_in; |
| 377 | 486 | |
| 378 | 487 | // Refresh if forced, expired, or expiring within 5 minutes (300 seconds) |
| 379 | - if ($force || $current_time >= ($expiration_time - 300)) { | |
| 488 | + if (!$force && $current_time < ($expiration_time - 300)) { | |
| 489 | + return; | |
| 490 | + } | |
| 380 | 491 | |
| 492 | + // A failed exchange leaves google_token_created untouched, so the | |
| 493 | + // expiry condition above stays true and the next request tries again. | |
| 494 | + // Without a backoff a proxy outage means one blocking network call per | |
| 495 | + // request, forever. A forced refresh — the user reconnecting — is a | |
| 496 | + // deliberate act and skips the wait (#383). | |
| 497 | + if (!$force && get_transient(self::REFRESH_BACKOFF)) { | |
| 498 | + return; | |
| 499 | + } | |
| 500 | + | |
| 501 | + // One exchange at a time. Concurrent callers past the expiry threshold | |
| 502 | + // would otherwise all refresh at once and invalidate each other's | |
| 503 | + // in-flight grants; the losers fall through with the current token and | |
| 504 | + // pick up the new one on their next read. | |
| 505 | + if (!$force && !$this->acquire_refresh_lock()) { | |
| 506 | + return; | |
| 507 | + } | |
| 508 | + | |
| 509 | + try { | |
| 381 | 510 | // The proxy owns the Google app credentials; we only ever hand it |
| 382 | 511 | // the refresh token and let it perform the exchange. |
| 383 | 512 | $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [ |
| 384 | 513 | 'headers' => [ |
| @@ -389,12 +518,13 @@ | ||
| 389 | 518 | 'action' => 'refresh', |
| 390 | 519 | 'refresh_token' => $refresh_token, |
| 391 | 520 | 'site' => home_url(), |
| 392 | 521 | ]), |
| 393 | - 'timeout' => 30 | |
| 522 | + 'timeout' => self::REFRESH_TIMEOUT | |
| 394 | 523 | ]); |
| 395 | 524 | |
| 396 | 525 | if (is_wp_error($response)) { |
| 526 | + $this->back_off_refresh(); | |
| 397 | 527 | return; |
| 398 | 528 | } |
| 399 | 529 | |
| 400 | 530 | $body = wp_remote_retrieve_body($response); |
| @@ -407,12 +537,15 @@ | ||
| 407 | 537 | // the site is connected — otherwise the UI shows "Connected" |
| 408 | 538 | // while every API call 401s. |
| 409 | 539 | if (($data['error'] ?? '') === 'invalid_grant') { |
| 410 | 540 | Google_OAuth_Proxy::mark_revoked(); |
| 541 | + return; | |
| 411 | 542 | } |
| 412 | 543 | |
| 413 | 544 | // Any other failure (network blip, proxy 502) is transient; |
| 414 | - // leave the credentials alone and let the next run retry. | |
| 545 | + // leave the credentials alone and let the next run retry — | |
| 546 | + // after the backoff, not on the very next request. | |
| 547 | + $this->back_off_refresh(); | |
| 415 | 548 | return; |
| 416 | 549 | } |
| 417 | 550 | |
| 418 | 551 | // Update settings with new token data |
| @@ -428,15 +561,79 @@ | ||
| 428 | 561 | 'google_refresh_token' => $data['refresh_token'] |
| 429 | 562 | ], 'integrations'); |
| 430 | 563 | } |
| 431 | 564 | |
| 565 | + // A success clears any backoff a previous failure left behind. | |
| 566 | + delete_transient(self::REFRESH_BACKOFF); | |
| 567 | + | |
| 432 | 568 | // Drop the memoized settings merge so subsequent reads (e.g. |
| 433 | 569 | // re-initializing clients) see the fresh token. |
| 434 | 570 | $this->merged_settings = null; |
| 571 | + } finally { | |
| 572 | + $this->release_refresh_lock(); | |
| 435 | 573 | } |
| 436 | 574 | } |
| 437 | 575 | |
| 438 | 576 | /** |
| 577 | + * Take the single-flight lock for the refresh exchange. | |
| 578 | + * | |
| 579 | + * @since 2.0.1 | |
| 580 | + * @return bool True when this request holds the lock. | |
| 581 | + */ | |
| 582 | + private function acquire_refresh_lock(): bool { | |
| 583 | + // With a persistent object cache, add is atomic — memcached and Redis | |
| 584 | + // both fail an ADD on an existing key — so exactly one caller wins. | |
| 585 | + if (wp_using_ext_object_cache()) { | |
| 586 | + return (bool) wp_cache_add(self::REFRESH_LOCK, time(), 'thinkrank', self::REFRESH_LOCK_TTL); | |
| 587 | + } | |
| 588 | + | |
| 589 | + // Without one, the options table is the shared store, and the unique | |
| 590 | + // index on option_name gives add_option() the same all-or-nothing | |
| 591 | + // result. set_transient() would not: it is an update, so every | |
| 592 | + // concurrent caller would "win". | |
| 593 | + if (add_option(self::REFRESH_LOCK, time(), '', 'no')) { | |
| 594 | + return true; | |
| 595 | + } | |
| 596 | + | |
| 597 | + // Reclaim a lock whose holder died before releasing it. | |
| 598 | + $held = (int) get_option(self::REFRESH_LOCK); | |
| 599 | + | |
| 600 | + if ($held > 0 && (time() - $held) > self::REFRESH_LOCK_TTL) { | |
| 601 | + delete_option(self::REFRESH_LOCK); | |
| 602 | + | |
| 603 | + return (bool) add_option(self::REFRESH_LOCK, time(), '', 'no'); | |
| 604 | + } | |
| 605 | + | |
| 606 | + return false; | |
| 607 | + } | |
| 608 | + | |
| 609 | + /** | |
| 610 | + * Release the single-flight lock. | |
| 611 | + * | |
| 612 | + * @since 2.0.1 | |
| 613 | + * @return void | |
| 614 | + */ | |
| 615 | + private function release_refresh_lock(): void { | |
| 616 | + if (wp_using_ext_object_cache()) { | |
| 617 | + wp_cache_delete(self::REFRESH_LOCK, 'thinkrank'); | |
| 618 | + | |
| 619 | + return; | |
| 620 | + } | |
| 621 | + | |
| 622 | + delete_option(self::REFRESH_LOCK); | |
| 623 | + } | |
| 624 | + | |
| 625 | + /** | |
| 626 | + * Stop retrying the exchange for a while after a failure. | |
| 627 | + * | |
| 628 | + * @since 2.0.1 | |
| 629 | + * @return void | |
| 630 | + */ | |
| 631 | + private function back_off_refresh(): void { | |
| 632 | + set_transient(self::REFRESH_BACKOFF, time(), self::REFRESH_BACKOFF_TTL); | |
| 633 | + } | |
| 634 | + | |
| 635 | + /** | |
| 439 | 636 | * Test all Google API connections |
| 440 | 637 | * Following ThinkRank test_connection patterns |
| 441 | 638 | * |
| 442 | 639 | * @return array Connection test results |
| @@ -441,66 +638,72 @@ | ||
| 441 | 638 | * |
| 442 | 639 | * @return array Connection test results |
| 443 | 640 | */ |
| 444 | 641 | public function test_connections(): array { |
| 642 | + // Every entry carries a `message`, configured or not: the result is | |
| 643 | + // handed to the Integrations screen as JSON, and a key that exists on | |
| 644 | + // some services and not others reads as `undefined` there. | |
| 445 | 645 | $results = [ |
| 446 | - 'google_analytics' => ['status' => 'not_configured'], | |
| 447 | - 'search_console' => ['status' => 'not_configured'], | |
| 448 | - 'pagespeed' => ['status' => 'not_configured'] | |
| 646 | + 'google_analytics' => ['status' => 'not_configured', 'message' => ''], | |
| 647 | + 'search_console' => ['status' => 'not_configured', 'message' => ''], | |
| 648 | + 'pagespeed' => ['status' => 'not_configured', 'message' => ''] | |
| 449 | 649 | ]; |
| 450 | 650 | |
| 451 | - // Test Google Analytics connection | |
| 452 | - if ($this->analytics_client) { | |
| 453 | - try { | |
| 454 | - $test_result = $this->analytics_client->test_connection(); | |
| 455 | - $results['google_analytics'] = [ | |
| 456 | - 'status' => $test_result['success'] ? 'connected' : 'error', | |
| 457 | - 'message' => $test_result['message'], | |
| 458 | - 'details' => $test_result | |
| 459 | - ]; | |
| 460 | - } catch (\Exception $e) { | |
| 461 | - $results['google_analytics'] = [ | |
| 462 | - 'status' => 'error', | |
| 463 | - 'message' => $e->getMessage() | |
| 464 | - ]; | |
| 651 | + // One shape for all three. They were three copies of the same block | |
| 652 | + // differing only in the client, which is how the same unguarded read | |
| 653 | + // came to exist in triplicate (#852). | |
| 654 | + $clients = [ | |
| 655 | + 'google_analytics' => $this->analytics_client, | |
| 656 | + 'search_console' => $this->search_console_client, | |
| 657 | + 'pagespeed' => $this->pagespeed_client, | |
| 658 | + ]; | |
| 659 | + | |
| 660 | + foreach ($clients as $service => $client) { | |
| 661 | + if (!$client) { | |
| 662 | + continue; | |
| 465 | 663 | } |
| 664 | + | |
| 665 | + $results[$service] = $this->describe_connection_test($client); | |
| 466 | 666 | } |
| 467 | 667 | |
| 468 | - // Test Search Console connection | |
| 469 | - if ($this->search_console_client) { | |
| 470 | - try { | |
| 471 | - $test_result = $this->search_console_client->test_connection(); | |
| 472 | - $results['search_console'] = [ | |
| 473 | - 'status' => $test_result['success'] ? 'connected' : 'error', | |
| 474 | - 'message' => $test_result['message'], | |
| 475 | - 'details' => $test_result | |
| 476 | - ]; | |
| 477 | - } catch (\Exception $e) { | |
| 478 | - $results['search_console'] = [ | |
| 479 | - 'status' => 'error', | |
| 480 | - 'message' => $e->getMessage() | |
| 481 | - ]; | |
| 668 | + return $results; | |
| 669 | + } | |
| 670 | + | |
| 671 | + /** | |
| 672 | + * Run one client's connection test and report it in a fixed shape. | |
| 673 | + * | |
| 674 | + * The clients answer success with `message` and failure with `error`, and | |
| 675 | + * this read `$test_result['message']` unconditionally — so a failed test | |
| 676 | + * raised "Undefined array key" and handed the admin an error with | |
| 677 | + * `message => null`. The reason the client had in hand was the one thing | |
| 678 | + * the screen needed. The clients now set `message` on both branches; the | |
| 679 | + * fallbacks here cover a client that does not, including one that answers | |
| 680 | + * with neither key. | |
| 681 | + * | |
| 682 | + * @since 2.12.0 | |
| 683 | + * | |
| 684 | + * @param object $client A client exposing test_connection(): array. | |
| 685 | + * @return array{status:string, message:string, details?:array} | |
| 686 | + */ | |
| 687 | + private function describe_connection_test($client): array { | |
| 688 | + try { | |
| 689 | + $test_result = $client->test_connection(); | |
| 690 | + | |
| 691 | + if (!is_array($test_result)) { | |
| 692 | + return ['status' => 'error', 'message' => '']; | |
| 482 | 693 | } |
| 483 | - } | |
| 484 | 694 | |
| 485 | - // Test PageSpeed connection | |
| 486 | - if ($this->pagespeed_client) { | |
| 487 | - try { | |
| 488 | - $test_result = $this->pagespeed_client->test_connection(); | |
| 489 | - $results['pagespeed'] = [ | |
| 490 | - 'status' => $test_result['success'] ? 'connected' : 'error', | |
| 491 | - 'message' => $test_result['message'], | |
| 492 | - 'details' => $test_result | |
| 493 | - ]; | |
| 494 | - } catch (\Exception $e) { | |
| 495 | - $results['pagespeed'] = [ | |
| 496 | - 'status' => 'error', | |
| 497 | - 'message' => $e->getMessage() | |
| 498 | - ]; | |
| 499 | - } | |
| 695 | + return [ | |
| 696 | + 'status' => !empty($test_result['success']) ? 'connected' : 'error', | |
| 697 | + 'message' => (string) ($test_result['message'] ?? $test_result['error'] ?? ''), | |
| 698 | + 'details' => $test_result, | |
| 699 | + ]; | |
| 700 | + } catch (\Exception $e) { | |
| 701 | + return [ | |
| 702 | + 'status' => 'error', | |
| 703 | + 'message' => $e->getMessage(), | |
| 704 | + ]; | |
| 500 | 705 | } |
| 501 | - | |
| 502 | - return $results; | |
| 503 | 706 | } |
| 504 | 707 | |
| 505 | 708 | /** |
| 506 | 709 | * Get analytics dashboard data |
| @@ -511,9 +714,9 @@ | ||
| 511 | 714 | * |
| 512 | 715 | * @throws \Exception On failure. |
| 513 | 716 | */ |
| 514 | 717 | public function get_dashboard_data(string $date_range = '30d'): array { |
| 515 | - $cache_key = "analytics_dashboard_v5_{$date_range}"; | |
| 718 | + $cache_key = self::DASHBOARD_CACHE_PREFIX . $date_range; | |
| 516 | 719 | $cached_data = get_transient($cache_key); |
| 517 | 720 | |
| 518 | 721 | if ($cached_data !== false) { |
| 519 | 722 | // Core Web Vitals are cached separately with a much shorter |
| @@ -550,10 +753,8 @@ | ||
| 550 | 753 | // 401s are re-thrown so the token-refresh retry below runs. |
| 551 | 754 | if ($this->analytics_client) { |
| 552 | 755 | try { |
| 553 | 756 | $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range); |
| 554 | - $dashboard_data['organic_traffic'] = $this->analytics_client->get_organic_traffic($date_range); | |
| 555 | - $dashboard_data['top_pages'] = $this->analytics_client->get_top_pages(10, $date_range); | |
| 556 | 757 | } catch (\Exception $ga_error) { |
| 557 | 758 | if ($ga_error->getCode() === 401) { |
| 558 | 759 | throw $ga_error; |
| 559 | 760 | } |
| @@ -586,35 +787,21 @@ | ||
| 586 | 787 | // Fallback to old client if new one fails init (shouldn't happen if they use same creds) |
| 587 | 788 | $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000); |
| 588 | 789 | } |
| 589 | 790 | |
| 590 | - // Calculate position distribution | |
| 591 | - $position_distribution = [ | |
| 592 | - 'top_3' => 0, | |
| 593 | - '4_10' => 0, | |
| 594 | - '10_50' => 0, | |
| 595 | - '51_100' => 0 | |
| 596 | - ]; | |
| 791 | + // Position distribution over every query with an | |
| 792 | + // impression, not over the 1,000-row list above (#913). | |
| 793 | + $position_distribution = $this->search_analytics_client | |
| 794 | + ? $this->count_position_distribution($site_url, $start_date, $end_date, $search_performance['rows'] ?? []) | |
| 795 | + : self::bucket_positions( | |
| 796 | + $search_performance['rows'] ?? [], | |
| 797 | + count($search_performance['rows'] ?? []) < 1000 | |
| 798 | + ); | |
| 597 | 799 | |
| 598 | - foreach ($search_performance['rows'] ?? [] as $row) { | |
| 599 | - $position = $row['position'] ?? 0; | |
| 600 | - if ($position <= 3) { | |
| 601 | - $position_distribution['top_3']++; | |
| 602 | - } elseif ($position <= 10) { | |
| 603 | - $position_distribution['4_10']++; | |
| 604 | - } elseif ($position <= 50) { | |
| 605 | - $position_distribution['10_50']++; | |
| 606 | - } elseif ($position <= 100) { | |
| 607 | - $position_distribution['51_100']++; | |
| 608 | - } | |
| 609 | - } | |
| 610 | - | |
| 611 | 800 | $dashboard_data['search_performance'] = array_merge($search_performance, [ |
| 612 | 801 | 'totals' => $totals, |
| 613 | 802 | 'position_distribution' => $position_distribution |
| 614 | 803 | ]); |
| 615 | - | |
| 616 | - $dashboard_data['page_performance'] = $this->search_console_client->get_page_performance($site_url, $date_range, 10); | |
| 617 | 804 | } // Closing Search Console block |
| 618 | 805 | |
| 619 | 806 | // If successful, break loop |
| 620 | 807 | break; |
| @@ -652,8 +839,132 @@ | ||
| 652 | 839 | return $dashboard_data; |
| 653 | 840 | } |
| 654 | 841 | |
| 655 | 842 | /** |
| 843 | + * Rows per page when counting the position distribution. The most the | |
| 844 | + * Search Analytics API returns in one request. | |
| 845 | + * | |
| 846 | + * @since 2.15.0 | |
| 847 | + */ | |
| 848 | + private const POSITION_PAGE_SIZE = 25000; | |
| 849 | + | |
| 850 | + /** | |
| 851 | + * Pages read before the count stops: 200,000 queries. A property past | |
| 852 | + * that is counted over its top 200,000 by clicks and flagged incomplete. | |
| 853 | + * | |
| 854 | + * @since 2.15.0 | |
| 855 | + */ | |
| 856 | + private const POSITION_MAX_PAGES = 8; | |
| 857 | + | |
| 858 | + /** | |
| 859 | + * Count the period's queries into position buckets across the whole | |
| 860 | + * property (#913). | |
| 861 | + * | |
| 862 | + * The dashboard's query list is capped at 1,000 rows ordered by clicks, | |
| 863 | + * so counting it told any larger site it had exactly 1,000 queries and | |
| 864 | + * dropped the long tail, which is where positions 51-100 live. When that | |
| 865 | + * list came back short it already holds every query and is counted as | |
| 866 | + * is, with no extra request. Otherwise the property is paged with | |
| 867 | + * `startRow` at POSITION_PAGE_SIZE rows until a short page, counting as | |
| 868 | + * rows arrive rather than keeping them. | |
| 869 | + * | |
| 870 | + * A page that fails (other than a 401, which is re-thrown so the token | |
| 871 | + * refresh runs) leaves the count at what was read so far, flagged | |
| 872 | + * `complete: false`, rather than failing the whole dashboard. | |
| 873 | + * | |
| 874 | + * @since 2.15.0 | |
| 875 | + * | |
| 876 | + * @param string $site_url Search Console property. | |
| 877 | + * @param string $start_date Window start (Y-m-d). | |
| 878 | + * @param string $end_date Window end (Y-m-d). | |
| 879 | + * @param array $first_rows The capped query list already fetched. | |
| 880 | + * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool} | |
| 881 | + * @throws \Exception On a 401, so get_dashboard_data() can refresh the token. | |
| 882 | + */ | |
| 883 | + private function count_position_distribution(string $site_url, string $start_date, string $end_date, array $first_rows): array { | |
| 884 | + if (count($first_rows) < 1000) { | |
| 885 | + return self::bucket_positions($first_rows, true); | |
| 886 | + } | |
| 887 | + | |
| 888 | + $distribution = self::bucket_positions([], true); | |
| 889 | + $start_row = 0; | |
| 890 | + | |
| 891 | + for ($page = 0; $page < self::POSITION_MAX_PAGES; $page++) { | |
| 892 | + try { | |
| 893 | + $rows = $this->search_analytics_client->get_search_analytics_data( | |
| 894 | + $site_url, | |
| 895 | + $start_date, | |
| 896 | + $end_date, | |
| 897 | + ['query'], | |
| 898 | + self::POSITION_PAGE_SIZE, | |
| 899 | + $start_row | |
| 900 | + )['rows'] ?? []; | |
| 901 | + } catch (\Exception $e) { | |
| 902 | + if ($e->getCode() === 401) { | |
| 903 | + throw $e; | |
| 904 | + } | |
| 905 | + // Nothing read yet: the capped list is the best there is. | |
| 906 | + $partial = $start_row === 0 ? self::bucket_positions($first_rows, false) : $distribution; | |
| 907 | + $partial['complete'] = false; | |
| 908 | + return $partial; | |
| 909 | + } | |
| 910 | + | |
| 911 | + $page_counts = self::bucket_positions($rows, true); | |
| 912 | + foreach (['top_3', '4_10', '10_50', '51_100', 'over_100'] as $bucket) { | |
| 913 | + $distribution[$bucket] += $page_counts[$bucket]; | |
| 914 | + } | |
| 915 | + | |
| 916 | + if (count($rows) < self::POSITION_PAGE_SIZE) { | |
| 917 | + return $distribution; | |
| 918 | + } | |
| 919 | + $start_row += self::POSITION_PAGE_SIZE; | |
| 920 | + } | |
| 921 | + | |
| 922 | + $distribution['complete'] = false; | |
| 923 | + return $distribution; | |
| 924 | + } | |
| 925 | + | |
| 926 | + /** | |
| 927 | + * Bucket Search Console rows by average position. | |
| 928 | + * | |
| 929 | + * `10_50` is the historical key for positions 11-50. Rows past 100 are | |
| 930 | + * counted in `over_100`: they still had impressions. | |
| 931 | + * | |
| 932 | + * @since 2.15.0 | |
| 933 | + * | |
| 934 | + * @param array $rows Search Console rows. | |
| 935 | + * @param bool $complete Whether $rows is every query in the window. | |
| 936 | + * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool} | |
| 937 | + */ | |
| 938 | + private static function bucket_positions(array $rows, bool $complete): array { | |
| 939 | + $distribution = [ | |
| 940 | + 'top_3' => 0, | |
| 941 | + '4_10' => 0, | |
| 942 | + '10_50' => 0, | |
| 943 | + '51_100' => 0, | |
| 944 | + 'over_100' => 0, | |
| 945 | + 'complete' => $complete, | |
| 946 | + ]; | |
| 947 | + | |
| 948 | + foreach ($rows as $row) { | |
| 949 | + $position = (float) ($row['position'] ?? 0); | |
| 950 | + if ($position <= 3) { | |
| 951 | + $distribution['top_3']++; | |
| 952 | + } elseif ($position <= 10) { | |
| 953 | + $distribution['4_10']++; | |
| 954 | + } elseif ($position <= 50) { | |
| 955 | + $distribution['10_50']++; | |
| 956 | + } elseif ($position <= 100) { | |
| 957 | + $distribution['51_100']++; | |
| 958 | + } else { | |
| 959 | + $distribution['over_100']++; | |
| 960 | + } | |
| 961 | + } | |
| 962 | + | |
| 963 | + return $distribution; | |
| 964 | + } | |
| 965 | + | |
| 966 | + /** | |
| 656 | 967 | * Get Core Web Vitals for the analytics dashboard, cached independently |
| 657 | 968 | * of the dashboard payload. |
| 658 | 969 | * |
| 659 | 970 | * Successful results are cached for 1 hour; failures are never cached |
| @@ -696,9 +1007,9 @@ | ||
| 696 | 1007 | * @param string $date_range Date range for analysis |
| 697 | 1008 | * @return array SEO opportunities |
| 698 | 1009 | */ |
| 699 | 1010 | public function get_seo_opportunities(string $date_range = '30d'): array { |
| 700 | - $cache_key = "seo_opportunities_{$date_range}"; | |
| 1011 | + $cache_key = self::OPPORTUNITIES_CACHE_PREFIX . $date_range; | |
| 701 | 1012 | $cached_data = get_transient($cache_key); |
| 702 | 1013 | |
| 703 | 1014 | if ($cached_data !== false) { |
| 704 | 1015 | return $cached_data; |
| @@ -812,41 +1123,24 @@ | ||
| 812 | 1123 | } |
| 813 | 1124 | } |
| 814 | 1125 | |
| 815 | 1126 | /** |
| 816 | - * Get site indexing status | |
| 1127 | + * Every transient the dashboard and opportunities caches are written | |
| 1128 | + * under. One list, so "Refresh data", the settings save and any later | |
| 1129 | + * caller clear the same keys the readers use. | |
| 817 | 1130 | * |
| 818 | - * @return array Indexing status data | |
| 1131 | + * @since 2.14.2 | |
| 1132 | + * @return string[] | |
| 819 | 1133 | */ |
| 820 | - public function get_indexing_status(): array { | |
| 821 | - $cache_key = 'indexing_status'; | |
| 822 | - $cached_data = get_transient($cache_key); | |
| 823 | - | |
| 824 | - if ($cached_data !== false) { | |
| 825 | - return $cached_data; | |
| 1134 | + public static function dashboard_cache_keys(): array { | |
| 1135 | + $keys = []; | |
| 1136 | + foreach (self::CACHED_DASHBOARD_RANGES as $range) { | |
| 1137 | + $keys[] = self::DASHBOARD_CACHE_PREFIX . $range; | |
| 826 | 1138 | } |
| 827 | - | |
| 828 | - $indexing_data = [ | |
| 829 | - 'status' => 'unknown', | |
| 830 | - 'last_updated' => current_time('mysql') | |
| 831 | - ]; | |
| 832 | - | |
| 833 | - try { | |
| 834 | - if ($this->search_console_client) { | |
| 835 | - $site_url = $this->get_setting('search_console_property', get_site_url()); | |
| 836 | - $indexing_data = $this->search_console_client->get_indexing_status($site_url); | |
| 837 | - } | |
| 838 | - } catch (\Exception $e) { | |
| 839 | - $indexing_data['error'] = $e->getMessage(); | |
| 1139 | + foreach (self::CACHED_OPPORTUNITY_RANGES as $range) { | |
| 1140 | + $keys[] = self::OPPORTUNITIES_CACHE_PREFIX . $range; | |
| 840 | 1141 | } |
| 841 | - | |
| 842 | - // Cache successful results for 1 hour. Errors are never cached, so a | |
| 843 | - // transient Google failure isn't served as "no data" for a full hour. | |
| 844 | - if (empty($indexing_data['error'])) { | |
| 845 | - set_transient($cache_key, $indexing_data, 3600); | |
| 846 | - } | |
| 847 | - | |
| 848 | - return $indexing_data; | |
| 1142 | + return $keys; | |
| 849 | 1143 | } |
| 850 | 1144 | |
| 851 | 1145 | /** |
| 852 | 1146 | * Force refresh of all cached data |
| @@ -856,24 +1150,12 @@ | ||
| 856 | 1150 | public function refresh_data(): array { |
| 857 | 1151 | // Clear all analytics-related transients, including the previous-period |
| 858 | 1152 | // ranges used for trend comparison (14d/60d/180d) and the separately |
| 859 | 1153 | // cached Core Web Vitals payload. |
| 860 | - $cache_keys = [ | |
| 861 | - 'analytics_dashboard_v5_7d', | |
| 862 | - 'analytics_dashboard_v5_30d', | |
| 863 | - 'analytics_dashboard_v5_90d', | |
| 864 | - 'analytics_dashboard_v5_14d', | |
| 865 | - 'analytics_dashboard_v5_60d', | |
| 866 | - 'analytics_dashboard_v5_180d', | |
| 867 | - 'seo_opportunities_7d', | |
| 868 | - 'seo_opportunities_30d', | |
| 869 | - 'seo_opportunities_90d', | |
| 870 | - 'seo_insights_7d', | |
| 871 | - 'seo_insights_30d', | |
| 872 | - 'seo_insights_90d', | |
| 873 | - 'indexing_status', | |
| 874 | - 'thinkrank_dashboard_cwv' | |
| 875 | - ]; | |
| 1154 | + $cache_keys = array_merge( | |
| 1155 | + self::dashboard_cache_keys(), | |
| 1156 | + ['indexing_status', 'thinkrank_dashboard_cwv'] | |
| 1157 | + ); | |
| 876 | 1158 | |
| 877 | 1159 | // Also clear PageSpeed-derived caches. Their keys are md5-derived from |
| 878 | 1160 | // URL + device, so compute them for the URL/device combinations the |
| 879 | 1161 | // plugin actually tests. |
| @@ -938,411 +1220,6 @@ | ||
| 938 | 1220 | */ |
| 939 | 1221 | public function cleanup_cache(): void { |
| 940 | 1222 | // WordPress handles transient cleanup automatically |
| 941 | 1223 | // This method is for future custom cache cleanup if needed |
| 942 | - } | |
| 943 | - | |
| 944 | - // ======================================== | |
| 945 | - // SEO Intelligence Enhancement Methods | |
| 946 | - // ======================================== | |
| 947 | - | |
| 948 | - /** | |
| 949 | - * Get intelligent dashboard data with trends and insights | |
| 950 | - * | |
| 951 | - * @param string $date_range Date range for analysis | |
| 952 | - * @return array Enhanced dashboard data with intelligence | |
| 953 | - */ | |
| 954 | - public function get_intelligent_dashboard_data(string $date_range = '30d'): array { | |
| 955 | - // Get base dashboard data | |
| 956 | - $dashboard_data = $this->get_dashboard_data($date_range); | |
| 957 | - | |
| 958 | - // Check if there's an error in the data | |
| 959 | - if (isset($dashboard_data['error'])) { | |
| 960 | - return [ | |
| 961 | - 'success' => false, | |
| 962 | - 'data' => null, | |
| 963 | - 'message' => 'Failed to retrieve dashboard data: ' . $dashboard_data['error'], | |
| 964 | - 'timestamp' => current_time('mysql') | |
| 965 | - ]; | |
| 966 | - } | |
| 967 | - | |
| 968 | - // Check if we have real data available | |
| 969 | - if (!$this->has_real_data($dashboard_data)) { | |
| 970 | - return [ | |
| 971 | - 'success' => false, | |
| 972 | - 'data' => null, | |
| 973 | - 'message' => 'No analytics data available yet. Please ensure your Google Analytics and Search Console are properly configured and have collected data.', | |
| 974 | - 'timestamp' => current_time('mysql') | |
| 975 | - ]; | |
| 976 | - } | |
| 977 | - | |
| 978 | - // Intelligence engine is a Pro-only feature. The four SEO_* classes ship | |
| 979 | - // in Free too (the PSR-4 autoloader would resolve them), so class_exists() | |
| 980 | - // can't gate this — check the real Pro signal instead. | |
| 981 | - if (!Plan_Config::is_pro()) { | |
| 982 | - return [ | |
| 983 | - 'success' => false, | |
| 984 | - 'data' => null, | |
| 985 | - 'message' => 'Intelligent dashboard requires ThinkRank Pro.', | |
| 986 | - 'timestamp' => current_time('mysql') | |
| 987 | - ]; | |
| 988 | - } | |
| 989 | - | |
| 990 | - $trend_analyzer = new SEO_Trend_Analyzer(); | |
| 991 | - $scoring_engine = new SEO_Scoring_Engine(); | |
| 992 | - $insight_generator = new SEO_Insight_Generator(); | |
| 993 | - | |
| 994 | - $data = $dashboard_data; | |
| 995 | - | |
| 996 | - // Generate trend analysis | |
| 997 | - $current_data = $data; | |
| 998 | - $historical_data = $this->get_historical_data($date_range); | |
| 999 | - | |
| 1000 | - $trends = [ | |
| 1001 | - 'traffic_trends' => $trend_analyzer->analyze_traffic_trends($current_data, $historical_data), | |
| 1002 | - 'keyword_trends' => $trend_analyzer->analyze_keyword_trends($data['search_performance'] ?? [], $date_range), | |
| 1003 | - 'content_trends' => $trend_analyzer->analyze_content_trends($data, $data['search_performance'] ?? []) | |
| 1004 | - ]; | |
| 1005 | - | |
| 1006 | - // Calculate SEO health score | |
| 1007 | - $seo_health = $scoring_engine->calculate_seo_health_score($data, $data['search_performance'] ?? []); | |
| 1008 | - | |
| 1009 | - // Generate insights | |
| 1010 | - $insights = [ | |
| 1011 | - 'traffic_insights' => $insight_generator->generate_traffic_insights($trends['traffic_trends']), | |
| 1012 | - 'keyword_insights' => $insight_generator->generate_keyword_insights($trends['keyword_trends']), | |
| 1013 | - 'content_insights' => $insight_generator->generate_content_insights($trends['content_trends']) | |
| 1014 | - ]; | |
| 1015 | - | |
| 1016 | - // Combine all intelligence data | |
| 1017 | - $enhanced_data = array_merge($data, [ | |
| 1018 | - 'intelligence' => [ | |
| 1019 | - 'trends' => $trends, | |
| 1020 | - 'seo_health_score' => $seo_health, | |
| 1021 | - 'insights' => $insights, | |
| 1022 | - 'last_analyzed' => current_time('mysql') | |
| 1023 | - ] | |
| 1024 | - ]); | |
| 1025 | - | |
| 1026 | - return [ | |
| 1027 | - 'success' => true, | |
| 1028 | - 'data' => $enhanced_data, | |
| 1029 | - 'message' => 'Intelligent dashboard data retrieved successfully' | |
| 1030 | - ]; | |
| 1031 | - } | |
| 1032 | - | |
| 1033 | - /** | |
| 1034 | - * Get intelligent SEO opportunities with prioritization | |
| 1035 | - * | |
| 1036 | - * @param string $date_range Date range for analysis | |
| 1037 | - * @return array Enhanced opportunities with intelligence | |
| 1038 | - */ | |
| 1039 | - public function get_intelligent_seo_opportunities(string $date_range = '30d'): array { | |
| 1040 | - // Get base opportunities data | |
| 1041 | - $opportunities_data = $this->get_seo_opportunities($date_range); | |
| 1042 | - | |
| 1043 | - // Check if there's an error in the data | |
| 1044 | - if (isset($opportunities_data['error'])) { | |
| 1045 | - return [ | |
| 1046 | - 'success' => false, | |
| 1047 | - 'data' => null, | |
| 1048 | - 'message' => 'Failed to retrieve opportunities data: ' . $opportunities_data['error'], | |
| 1049 | - 'timestamp' => current_time('mysql') | |
| 1050 | - ]; | |
| 1051 | - } | |
| 1052 | - | |
| 1053 | - // The opportunities payload itself has no search_performance key — that | |
| 1054 | - // data lives in the (cached) dashboard payload. Pull it from there both | |
| 1055 | - // for the availability check and as input for the opportunity detectors; | |
| 1056 | - // checking $opportunities_data['search_performance'] here used to make | |
| 1057 | - // this method always bail with "No Search Console data available". | |
| 1058 | - $dashboard_data = $this->get_dashboard_data($date_range); | |
| 1059 | - $search_performance = $dashboard_data['search_performance'] ?? []; | |
| 1060 | - $opportunities_data['search_performance'] = $search_performance; | |
| 1061 | - | |
| 1062 | - $has_search_data = !empty($search_performance['rows']) || | |
| 1063 | - ($search_performance['total_clicks'] ?? 0) > 0 || | |
| 1064 | - ($search_performance['total_impressions'] ?? 0) > 0; | |
| 1065 | - | |
| 1066 | - if (!$has_search_data) { | |
| 1067 | - return [ | |
| 1068 | - 'success' => false, | |
| 1069 | - 'data' => null, | |
| 1070 | - 'message' => 'No Search Console data available yet. Please ensure your Search Console is properly configured and has collected data.', | |
| 1071 | - 'timestamp' => current_time('mysql') | |
| 1072 | - ]; | |
| 1073 | - } | |
| 1074 | - | |
| 1075 | - // Intelligence engine is a Pro-only feature — gate on the real Pro signal, | |
| 1076 | - // not class_exists() (the classes ship in Free and would autoload). | |
| 1077 | - if (!Plan_Config::is_pro()) { | |
| 1078 | - return [ | |
| 1079 | - 'success' => false, | |
| 1080 | - 'data' => null, | |
| 1081 | - 'message' => 'Intelligent opportunities require ThinkRank Pro.', | |
| 1082 | - 'timestamp' => current_time('mysql') | |
| 1083 | - ]; | |
| 1084 | - } | |
| 1085 | - | |
| 1086 | - $opportunity_detector = new SEO_Opportunity_Detector(); | |
| 1087 | - $scoring_engine = new SEO_Scoring_Engine(); | |
| 1088 | - | |
| 1089 | - $data = $opportunities_data; | |
| 1090 | - | |
| 1091 | - // Detect intelligent opportunities | |
| 1092 | - $search_console_data = $data['search_performance'] ?? []; | |
| 1093 | - $analytics_data = $data; | |
| 1094 | - | |
| 1095 | - $intelligent_opportunities = [ | |
| 1096 | - 'quick_wins' => $opportunity_detector->detect_quick_wins($search_console_data, $analytics_data), | |
| 1097 | - 'content_opportunities' => $opportunity_detector->identify_content_opportunities($search_console_data, $analytics_data), | |
| 1098 | - 'keyword_opportunities' => $scoring_engine->score_keyword_opportunities($search_console_data) | |
| 1099 | - ]; | |
| 1100 | - | |
| 1101 | - // Prioritize all opportunities. prioritize_opportunities() expects | |
| 1102 | - // category => [opportunities]; calculate_impact_effort_matrix() expects a flat list. | |
| 1103 | - $opportunities_by_category = [ | |
| 1104 | - 'quick_wins' => $intelligent_opportunities['quick_wins']['opportunities'] ?? [], | |
| 1105 | - 'content' => $intelligent_opportunities['content_opportunities']['opportunities'] ?? [], | |
| 1106 | - 'keywords' => $intelligent_opportunities['keyword_opportunities']['opportunities'] ?? [], | |
| 1107 | - ]; | |
| 1108 | - $all_opportunities = array_merge(...array_values($opportunities_by_category)); | |
| 1109 | - | |
| 1110 | - $prioritized = $opportunity_detector->prioritize_opportunities($opportunities_by_category); | |
| 1111 | - $impact_matrix = $opportunity_detector->calculate_impact_effort_matrix($all_opportunities); | |
| 1112 | - | |
| 1113 | - // Enhance original data with intelligence | |
| 1114 | - $enhanced_data = array_merge($data, [ | |
| 1115 | - 'intelligent_opportunities' => $intelligent_opportunities, | |
| 1116 | - 'prioritized_opportunities' => $prioritized, | |
| 1117 | - 'impact_effort_matrix' => $impact_matrix, | |
| 1118 | - 'opportunity_summary' => $this->generate_opportunity_summary($intelligent_opportunities), | |
| 1119 | - 'last_analyzed' => current_time('mysql') | |
| 1120 | - ]); | |
| 1121 | - | |
| 1122 | - return [ | |
| 1123 | - 'success' => true, | |
| 1124 | - 'data' => $enhanced_data, | |
| 1125 | - 'message' => 'Intelligent SEO opportunities retrieved successfully' | |
| 1126 | - ]; | |
| 1127 | - } | |
| 1128 | - | |
| 1129 | - /** | |
| 1130 | - * Get SEO performance insights | |
| 1131 | - * | |
| 1132 | - * @param string $date_range Date range for analysis | |
| 1133 | - * @return array SEO insights data | |
| 1134 | - */ | |
| 1135 | - public function get_seo_insights(string $date_range = '30d'): array { | |
| 1136 | - $cache_key = "seo_insights_{$date_range}"; | |
| 1137 | - $cached_data = get_transient($cache_key); | |
| 1138 | - | |
| 1139 | - if ($cached_data !== false) { | |
| 1140 | - return [ | |
| 1141 | - 'success' => true, | |
| 1142 | - 'data' => $cached_data, | |
| 1143 | - 'cached' => true, | |
| 1144 | - 'message' => 'SEO insights retrieved from cache' | |
| 1145 | - ]; | |
| 1146 | - } | |
| 1147 | - | |
| 1148 | - try { | |
| 1149 | - // Get dashboard data for analysis | |
| 1150 | - $dashboard_result = $this->get_intelligent_dashboard_data($date_range); | |
| 1151 | - | |
| 1152 | - if (!$dashboard_result['success']) { | |
| 1153 | - return $dashboard_result; | |
| 1154 | - } | |
| 1155 | - | |
| 1156 | - $dashboard_data = $dashboard_result['data']; | |
| 1157 | - $intelligence = $dashboard_data['intelligence'] ?? []; | |
| 1158 | - | |
| 1159 | - // Insights are a Pro-only feature — gate on the real Pro signal, | |
| 1160 | - // not class_exists() (SEO_Insight_Generator ships in Free too). | |
| 1161 | - if (!Plan_Config::is_pro()) { | |
| 1162 | - return [ | |
| 1163 | - 'success' => false, | |
| 1164 | - 'data' => null, | |
| 1165 | - 'message' => 'SEO insights require ThinkRank Pro.', | |
| 1166 | - 'timestamp' => current_time('mysql') | |
| 1167 | - ]; | |
| 1168 | - } | |
| 1169 | - | |
| 1170 | - $insight_generator = new SEO_Insight_Generator(); | |
| 1171 | - | |
| 1172 | - // Collect all insights | |
| 1173 | - $all_insights = []; | |
| 1174 | - | |
| 1175 | - if (!empty($intelligence['insights']['traffic_insights']['insights'])) { | |
| 1176 | - $all_insights = array_merge($all_insights, $intelligence['insights']['traffic_insights']['insights']); | |
| 1177 | - } | |
| 1178 | - | |
| 1179 | - if (!empty($intelligence['insights']['keyword_insights']['insights'])) { | |
| 1180 | - $all_insights = array_merge($all_insights, $intelligence['insights']['keyword_insights']['insights']); | |
| 1181 | - } | |
| 1182 | - | |
| 1183 | - if (!empty($intelligence['insights']['content_insights']['insights'])) { | |
| 1184 | - $all_insights = array_merge($all_insights, $intelligence['insights']['content_insights']['insights']); | |
| 1185 | - } | |
| 1186 | - | |
| 1187 | - // Format and prioritize insights | |
| 1188 | - $formatted_insights = $insight_generator->format_insights_for_display($all_insights); | |
| 1189 | - $prioritized_insights = $insight_generator->prioritize_insights_by_impact($formatted_insights); | |
| 1190 | - | |
| 1191 | - $insights_data = [ | |
| 1192 | - 'insights' => $prioritized_insights['prioritized_insights'], | |
| 1193 | - 'summary' => [ | |
| 1194 | - 'total_insights' => count($formatted_insights), | |
| 1195 | - 'high_impact_count' => $prioritized_insights['high_impact_count'], | |
| 1196 | - 'action_required_count' => $prioritized_insights['action_required_count'] | |
| 1197 | - ], | |
| 1198 | - 'seo_health_score' => $intelligence['seo_health_score'] ?? null, | |
| 1199 | - 'generated_at' => current_time('mysql') | |
| 1200 | - ]; | |
| 1201 | - | |
| 1202 | - // Cache the results | |
| 1203 | - set_transient($cache_key, $insights_data, $this->cache_duration); | |
| 1204 | - | |
| 1205 | - return [ | |
| 1206 | - 'success' => true, | |
| 1207 | - 'data' => $insights_data, | |
| 1208 | - 'cached' => false, | |
| 1209 | - 'message' => 'SEO insights generated successfully' | |
| 1210 | - ]; | |
| 1211 | - } catch (\Exception $e) { | |
| 1212 | - return [ | |
| 1213 | - 'success' => false, | |
| 1214 | - 'error' => 'Failed to generate SEO insights: ' . $e->getMessage(), | |
| 1215 | - 'data' => null | |
| 1216 | - ]; | |
| 1217 | - } | |
| 1218 | - } | |
| 1219 | - | |
| 1220 | - /** | |
| 1221 | - * Check if real analytics data is available | |
| 1222 | - * | |
| 1223 | - * @param array $dashboard_data Dashboard data to check | |
| 1224 | - * @return bool True if real data is available | |
| 1225 | - */ | |
| 1226 | - private function has_real_data(array $dashboard_data): bool { | |
| 1227 | - // Check if we have meaningful traffic data | |
| 1228 | - $traffic = $dashboard_data['traffic'] ?? []; | |
| 1229 | - $search_performance = $dashboard_data['search_performance'] ?? []; | |
| 1230 | - | |
| 1231 | - $has_traffic = !empty($traffic) && ( | |
| 1232 | - ($traffic['sessions'] ?? 0) > 0 || | |
| 1233 | - ($traffic['pageviews'] ?? 0) > 0 || | |
| 1234 | - ($traffic['active_users'] ?? 0) > 0 | |
| 1235 | - ); | |
| 1236 | - | |
| 1237 | - $has_search_data = !empty($search_performance) && ( | |
| 1238 | - !empty($search_performance['rows']) || | |
| 1239 | - ($search_performance['total_clicks'] ?? 0) > 0 || | |
| 1240 | - ($search_performance['total_impressions'] ?? 0) > 0 | |
| 1241 | - ); | |
| 1242 | - | |
| 1243 | - return $has_traffic || $has_search_data; | |
| 1244 | - } | |
| 1245 | - | |
| 1246 | - /** | |
| 1247 | - * Get historical data for trend comparison | |
| 1248 | - * | |
| 1249 | - * @param string $current_range Current date range | |
| 1250 | - * @return array Historical data | |
| 1251 | - */ | |
| 1252 | - private function get_historical_data(string $current_range): array { | |
| 1253 | - // Calculate previous period based on current range | |
| 1254 | - $previous_range = $this->calculate_previous_period($current_range); | |
| 1255 | - | |
| 1256 | - // Try to get actual historical data from previous period | |
| 1257 | - $historical_data = $this->get_dashboard_data($previous_range); | |
| 1258 | - | |
| 1259 | - // Return the actual historical data (may be empty if no real data available) | |
| 1260 | - return [ | |
| 1261 | - 'sessions' => $historical_data['traffic']['sessions'] ?? 0, | |
| 1262 | - 'pageviews' => $historical_data['traffic']['pageviews'] ?? 0, | |
| 1263 | - 'organic_traffic' => $historical_data['organic_traffic'] ?? ['organic_traffic' => ['sessions' => 0]], | |
| 1264 | - 'bounce_rate' => $historical_data['traffic']['bounce_rate'] ?? 0, | |
| 1265 | - 'avg_session_duration' => $historical_data['traffic']['avg_session_duration'] ?? 0 | |
| 1266 | - ]; | |
| 1267 | - } | |
| 1268 | - | |
| 1269 | - /** | |
| 1270 | - * Calculate previous period for comparison | |
| 1271 | - * | |
| 1272 | - * @param string $current_range Current range | |
| 1273 | - * @return string Previous period range | |
| 1274 | - */ | |
| 1275 | - private function calculate_previous_period(string $current_range): string { | |
| 1276 | - // Simple mapping for now - could be enhanced with actual date calculations | |
| 1277 | - $period_mapping = [ | |
| 1278 | - '7d' => '14d', | |
| 1279 | - '30d' => '60d', | |
| 1280 | - '90d' => '180d' | |
| 1281 | - ]; | |
| 1282 | - | |
| 1283 | - return $period_mapping[$current_range] ?? '60d'; | |
| 1284 | - } | |
| 1285 | - | |
| 1286 | - /** | |
| 1287 | - * Generate opportunity summary | |
| 1288 | - * | |
| 1289 | - * @param array $opportunities All opportunities | |
| 1290 | - * @return array Opportunity summary | |
| 1291 | - */ | |
| 1292 | - private function generate_opportunity_summary(array $opportunities): array { | |
| 1293 | - $quick_wins_count = count($opportunities['quick_wins']['opportunities'] ?? []); | |
| 1294 | - $content_opportunities_count = count($opportunities['content_opportunities']['opportunities'] ?? []); | |
| 1295 | - $keyword_opportunities_count = count($opportunities['keyword_opportunities']['opportunities'] ?? []); | |
| 1296 | - | |
| 1297 | - $total_opportunities = $quick_wins_count + $content_opportunities_count + $keyword_opportunities_count; | |
| 1298 | - | |
| 1299 | - $potential_clicks = 0; | |
| 1300 | - if (!empty($opportunities['quick_wins']['potential_additional_clicks'])) { | |
| 1301 | - $potential_clicks = $opportunities['quick_wins']['potential_additional_clicks']; | |
| 1302 | - } | |
| 1303 | - | |
| 1304 | - return [ | |
| 1305 | - 'total_opportunities' => $total_opportunities, | |
| 1306 | - 'quick_wins_count' => $quick_wins_count, | |
| 1307 | - 'content_opportunities_count' => $content_opportunities_count, | |
| 1308 | - 'keyword_opportunities_count' => $keyword_opportunities_count, | |
| 1309 | - 'potential_additional_clicks' => $potential_clicks, | |
| 1310 | - 'priority_recommendation' => $quick_wins_count > 0 ? | |
| 1311 | - 'Focus on quick wins first for immediate impact' : | |
| 1312 | - 'Focus on content optimization for long-term growth' | |
| 1313 | - ]; | |
| 1314 | - } | |
| 1315 | - | |
| 1316 | - /** | |
| 1317 | - * Clear intelligence cache | |
| 1318 | - * | |
| 1319 | - * @return array Clear result | |
| 1320 | - */ | |
| 1321 | - public function clear_intelligence_cache(): array { | |
| 1322 | - $intelligence_cache_keys = [ | |
| 1323 | - 'seo_insights_7d', | |
| 1324 | - 'seo_insights_30d', | |
| 1325 | - 'seo_insights_90d', | |
| 1326 | - 'intelligent_dashboard_7d', | |
| 1327 | - 'intelligent_dashboard_30d', | |
| 1328 | - 'intelligent_dashboard_90d', | |
| 1329 | - 'intelligent_opportunities_7d', | |
| 1330 | - 'intelligent_opportunities_30d', | |
| 1331 | - 'intelligent_opportunities_90d' | |
| 1332 | - ]; | |
| 1333 | - | |
| 1334 | - $cleared = 0; | |
| 1335 | - foreach ($intelligence_cache_keys as $key) { | |
| 1336 | - if (delete_transient($key)) { | |
| 1337 | - $cleared++; | |
| 1338 | - } | |
| 1339 | - } | |
| 1340 | - | |
| 1341 | - return [ | |
| 1342 | - 'success' => true, | |
| 1343 | - 'message' => "Cleared {$cleared} intelligence cache entries", | |
| 1344 | - 'cleared_count' => $cleared, | |
| 1345 | - 'timestamp' => current_time('mysql') | |
| 1346 | - ]; | |
| 1347 | 1224 | } |
| 1348 | 1225 | } |