← All changes
|
includes/integrations/class-google-pagespeed-client.php
+208
-17
1.0.2
→
2.7.0
View file →
| @@ -45,8 +45,100 @@ | ||
| 45 | 45 | */ |
| 46 | 46 | private const MAX_REQUESTS_PER_DAY = 25000; |
| 47 | 47 | |
| 48 | 48 | /** |
| 49 | + * How long a parsed PageSpeed snapshot is reused before a new live run (seconds) | |
| 50 | + */ | |
| 51 | + private const SNAPSHOT_TTL = 600; | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * How long a failed PageSpeed run is remembered before retrying (seconds) | |
| 55 | + * | |
| 56 | + * Lighthouse runs are slow (10-60s); without this, a failing URL (e.g. a | |
| 57 | + * non-public localhost) would block every admin request for the full HTTP | |
| 58 | + * timeout. | |
| 59 | + */ | |
| 60 | + private const FAILURE_TTL = 300; | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * Exception code marking a rethrown remembered failure rather than a live | |
| 64 | + * API error, so callers can report "try again shortly" instead of implying | |
| 65 | + * the request was actually attempted. | |
| 66 | + */ | |
| 67 | + public const CODE_REMEMBERED_FAILURE = 9001; | |
| 68 | + | |
| 69 | + /** | |
| 70 | + * Per-request memo of parsed snapshots, keyed by url|strategy | |
| 71 | + * | |
| 72 | + * @var array<string,array> | |
| 73 | + */ | |
| 74 | + private static array $snapshot_memo = []; | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * Default HTTP timeout for PageSpeed runs (seconds). | |
| 78 | + * | |
| 79 | + * Real Lighthouse runs routinely take 15-45s; the previous 20s timeout | |
| 80 | + * aborted a large share of otherwise-successful runs. | |
| 81 | + */ | |
| 82 | + public const DEFAULT_TIMEOUT = 45; | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * Build a client authenticated the way the PageSpeed API expects. | |
| 86 | + * | |
| 87 | + * Auth order (RankMath uses the same model, minus the key): | |
| 88 | + * 1. Site-owned API key — dedicated per-project quota, always reliable. | |
| 89 | + * 2. The user's Google OAuth token — works at low volume; quota is | |
| 90 | + * shared across the OAuth project, so ThinkRank must stay frugal | |
| 91 | + * (see the 7-day refresh gate in Performance_Data_Collector). | |
| 92 | + * 3. Keyless — Google's shared anonymous pool; last resort. | |
| 93 | + * | |
| 94 | + * @param int|null $timeout HTTP timeout in seconds (default self::DEFAULT_TIMEOUT) | |
| 95 | + * @return self | |
| 96 | + */ | |
| 97 | + public static function for_site(?int $timeout = null): self { | |
| 98 | + $api_key = ''; | |
| 99 | + $access_token = ''; | |
| 100 | + if (class_exists('\\ThinkRank\\Core\\Settings')) { | |
| 101 | + $settings = new \ThinkRank\Core\Settings(); | |
| 102 | + $api_key = (string) $settings->get('google_pagespeed_api_key', ''); | |
| 103 | + $access_token = (string) $settings->get('google_access_token', ''); | |
| 104 | + } | |
| 105 | + | |
| 106 | + if ($api_key !== '') { | |
| 107 | + // A dedicated key wins: pass no token so quota bills the key's project. | |
| 108 | + return new self($api_key, $timeout ?? self::DEFAULT_TIMEOUT, null); | |
| 109 | + } | |
| 110 | + | |
| 111 | + return new self('', $timeout ?? self::DEFAULT_TIMEOUT, $access_token !== '' ? $access_token : null); | |
| 112 | + } | |
| 113 | + | |
| 114 | + /** | |
| 115 | + * Whether the site holds a credential the PageSpeed API will accept. | |
| 116 | + * | |
| 117 | + * The counterpart to for_site(): either of the first two rungs of its auth | |
| 118 | + * order is enough, and a caller that wants to refuse the keyless third rung | |
| 119 | + * asks this rather than testing one credential itself. Callers that did the | |
| 120 | + * latter locked out every site configured with only an API key — the | |
| 121 | + * credential for_site() actually *prefers*, since a dedicated key bills its | |
| 122 | + * own project quota (#519). | |
| 123 | + * | |
| 124 | + * @since 2.1.1 | |
| 125 | + * | |
| 126 | + * @return bool True when an API key or an OAuth token is configured. | |
| 127 | + */ | |
| 128 | + public static function site_has_credentials(): bool { | |
| 129 | + if (!class_exists('\\ThinkRank\\Core\\Settings')) { | |
| 130 | + return false; | |
| 131 | + } | |
| 132 | + | |
| 133 | + $settings = new \ThinkRank\Core\Settings(); | |
| 134 | + | |
| 135 | + // OAuth tokens are encrypted at rest; Settings::get() decrypts them. | |
| 136 | + return '' !== trim((string) $settings->get('google_pagespeed_api_key', '')) | |
| 137 | + || '' !== trim((string) $settings->get('google_access_token', '')); | |
| 138 | + } | |
| 139 | + | |
| 140 | + /** | |
| 49 | 141 | * Run PageSpeed test for a URL |
| 50 | 142 | * |
| 51 | 143 | * @param string $url URL to test |
| 52 | 144 | * @param string $strategy Device strategy ('mobile' or 'desktop') |
| @@ -58,10 +150,11 @@ | ||
| 58 | 150 | $endpoint = '/runPagespeed'; |
| 59 | 151 | $params = [ |
| 60 | 152 | 'url' => $url, |
| 61 | 153 | 'strategy' => $strategy, |
| 62 | - 'category' => $categories, | |
| 63 | - 'key' => $this->api_key | |
| 154 | + // http_build_query would serialize an array as category[0]=…, | |
| 155 | + // which the PSI API ignores; a single category must be a scalar. | |
| 156 | + 'category' => count($categories) === 1 ? $categories[0] : $categories, | |
| 64 | 157 | ]; |
| 65 | 158 | |
| 66 | 159 | $full_url = self::API_BASE_URL . $endpoint; |
| 67 | 160 | return $this->make_request($full_url, $params, 'GET'); |
| @@ -67,8 +160,94 @@ | ||
| 67 | 160 | return $this->make_request($full_url, $params, 'GET'); |
| 68 | 161 | } |
| 69 | 162 | |
| 70 | 163 | /** |
| 164 | + * Get a parsed PageSpeed snapshot for a URL, from cache when possible. | |
| 165 | + * | |
| 166 | + * One live Lighthouse run produces Core Web Vitals, opportunities, | |
| 167 | + * diagnostics and the performance score together; callers that previously | |
| 168 | + * triggered separate runs for each now share a single cached result. | |
| 169 | + * Failures are remembered briefly (FAILURE_TTL) so a broken URL doesn't | |
| 170 | + * re-block every request for the full HTTP timeout. | |
| 171 | + * | |
| 172 | + * @param string $url URL to analyze | |
| 173 | + * @param string $strategy Device strategy ('mobile' or 'desktop') | |
| 174 | + * @return array{core_web_vitals:array,opportunities:array,diagnostics:array,performance_score:float,fetched_at:int} | |
| 175 | + * @throws \Exception If the API request fails (including remembered recent failures) | |
| 176 | + */ | |
| 177 | + public function get_pagespeed_snapshot(string $url, string $strategy = 'mobile', bool $fresh = false): array { | |
| 178 | + $memo_key = $url . '|' . $strategy; | |
| 179 | + $hash = md5($memo_key); | |
| 180 | + | |
| 181 | + // A user-initiated refresh must actually re-measure. The 7-day gate in | |
| 182 | + // Performance_Data_Collector was the only thing $force skipped, so a | |
| 183 | + // manual retry within FAILURE_TTL of any failure re-threw the remembered | |
| 184 | + // message in milliseconds without contacting Google — which made | |
| 185 | + // "refresh" useless for exactly the case people press it in, right after | |
| 186 | + // seeing an error. | |
| 187 | + if ($fresh) { | |
| 188 | + unset(self::$snapshot_memo[$memo_key]); | |
| 189 | + delete_transient('thinkrank_psi_snapshot_' . $hash); | |
| 190 | + delete_transient('thinkrank_psi_failure_' . $hash); | |
| 191 | + } | |
| 192 | + | |
| 193 | + if (isset(self::$snapshot_memo[$memo_key])) { | |
| 194 | + return self::$snapshot_memo[$memo_key]; | |
| 195 | + } | |
| 196 | + | |
| 197 | + $cached = get_transient('thinkrank_psi_snapshot_' . $hash); | |
| 198 | + if (is_array($cached)) { | |
| 199 | + self::$snapshot_memo[$memo_key] = $cached; | |
| 200 | + return $cached; | |
| 201 | + } | |
| 202 | + | |
| 203 | + $recent_failure = get_transient('thinkrank_psi_failure_' . $hash); | |
| 204 | + if (is_string($recent_failure) && $recent_failure !== '') { | |
| 205 | + throw new \Exception($recent_failure, self::CODE_REMEMBERED_FAILURE); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped | |
| 206 | + } | |
| 207 | + | |
| 208 | + try { | |
| 209 | + $result = $this->run_pagespeed_test($url, $strategy, ['performance']); | |
| 210 | + } catch (\Exception $e) { | |
| 211 | + set_transient('thinkrank_psi_failure_' . $hash, $e->getMessage(), self::FAILURE_TTL); | |
| 212 | + throw $e; | |
| 213 | + } | |
| 214 | + | |
| 215 | + // Lighthouse answers 200 with a populated lighthouseResult even when the | |
| 216 | + // audit itself failed (NO_FCP, ERRORED_DOCUMENT_REQUEST, …); the score | |
| 217 | + // then comes back null. Coercing that to 0 stored a failed run as a | |
| 218 | + // genuine "this site scores 0" measurement, which every consumer — | |
| 219 | + // the SEO score's mobile factor most visibly — has no way to tell from | |
| 220 | + // a real result. Treat it as the failure it is so the caller's existing | |
| 221 | + // failure handling applies. | |
| 222 | + $runtime_error = $result['lighthouseResult']['runtimeError']['code'] ?? ''; | |
| 223 | + $raw_score = $result['lighthouseResult']['categories']['performance']['score'] ?? null; | |
| 224 | + | |
| 225 | + if (('' !== $runtime_error && 'NO_ERROR' !== $runtime_error) || null === $raw_score) { | |
| 226 | + $message = $result['lighthouseResult']['runtimeError']['message'] | |
| 227 | + ?? __('PageSpeed Insights returned no performance score for this URL.', 'thinkrank'); | |
| 228 | + | |
| 229 | + set_transient('thinkrank_psi_failure_' . $hash, $message, self::FAILURE_TTL); | |
| 230 | + | |
| 231 | + throw new \Exception($message); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped | |
| 232 | + } | |
| 233 | + | |
| 234 | + $snapshot = [ | |
| 235 | + 'core_web_vitals' => $this->parse_core_web_vitals($result), | |
| 236 | + 'opportunities' => $this->parse_opportunities($result), | |
| 237 | + 'diagnostics' => $this->parse_diagnostics($result), | |
| 238 | + 'performance_score' => (float) ($raw_score * 100), | |
| 239 | + 'loading_experience' => $result['loadingExperience'] ?? [], | |
| 240 | + 'fetched_at' => time(), | |
| 241 | + ]; | |
| 242 | + | |
| 243 | + set_transient('thinkrank_psi_snapshot_' . $hash, $snapshot, self::SNAPSHOT_TTL); | |
| 244 | + self::$snapshot_memo[$memo_key] = $snapshot; | |
| 245 | + | |
| 246 | + return $snapshot; | |
| 247 | + } | |
| 248 | + | |
| 249 | + /** | |
| 71 | 250 | * Test API connection |
| 72 | 251 | * Following ThinkRank test_connection patterns from AI clients |
| 73 | 252 | * |
| 74 | 253 | * @return array Connection test results |
| @@ -105,10 +284,9 @@ | ||
| 105 | 284 | * @return array Core Web Vitals data |
| 106 | 285 | * @throws \Exception If API request fails |
| 107 | 286 | */ |
| 108 | 287 | public function get_core_web_vitals(string $url, string $strategy = 'mobile'): array { |
| 109 | - $result = $this->run_pagespeed_test($url, $strategy, ['performance']); | |
| 110 | - return $this->parse_core_web_vitals($result); | |
| 288 | + return $this->get_pagespeed_snapshot($url, $strategy)['core_web_vitals']; | |
| 111 | 289 | } |
| 112 | 290 | |
| 113 | 291 | /** |
| 114 | 292 | * Get performance opportunities for a URL |
| @@ -118,10 +296,9 @@ | ||
| 118 | 296 | * @return array Performance opportunities |
| 119 | 297 | * @throws \Exception If API request fails |
| 120 | 298 | */ |
| 121 | 299 | public function get_opportunities(string $url, string $strategy = 'mobile'): array { |
| 122 | - $result = $this->run_pagespeed_test($url, $strategy, ['performance']); | |
| 123 | - return $this->parse_opportunities($result); | |
| 300 | + return $this->get_pagespeed_snapshot($url, $strategy)['opportunities']; | |
| 124 | 301 | } |
| 125 | 302 | |
| 126 | 303 | /** |
| 127 | 304 | * Get diagnostic information for a URL |
| @@ -131,10 +308,9 @@ | ||
| 131 | 308 | * @return array Diagnostic information |
| 132 | 309 | * @throws \Exception If API request fails |
| 133 | 310 | */ |
| 134 | 311 | public function get_diagnostics(string $url, string $strategy = 'mobile'): array { |
| 135 | - $result = $this->run_pagespeed_test($url, $strategy, ['performance']); | |
| 136 | - return $this->parse_diagnostics($result); | |
| 312 | + return $this->get_pagespeed_snapshot($url, $strategy)['diagnostics']; | |
| 137 | 313 | } |
| 138 | 314 | |
| 139 | 315 | /** |
| 140 | 316 | * Parse Core Web Vitals from PageSpeed response |
| @@ -154,16 +330,32 @@ | ||
| 154 | 330 | 'good_threshold' => 2.5, |
| 155 | 331 | 'needs_improvement_threshold' => 4.0, |
| 156 | 332 | 'description' => 'Time until the largest content element is rendered' |
| 157 | 333 | ], |
| 158 | - 'fid' => [ | |
| 159 | - 'name' => 'First Input Delay', | |
| 160 | - 'value' => round(($audits['max-potential-fid']['numericValue'] ?? 0), 4), | |
| 161 | - 'score' => ($audits['max-potential-fid']['score'] ?? 0) * 100, | |
| 334 | + // INP replaced FID as a Core Web Vital in March 2024. INP is a field | |
| 335 | + // metric — a standard PSI navigation run has no interaction to | |
| 336 | + // measure — so Lighthouse only reports it in timespan mode. Read that | |
| 337 | + // audit when it is present and otherwise fall back to Total Blocking | |
| 338 | + // Time, which is Google's documented lab proxy for INP. Real INP | |
| 339 | + // comes from the CrUX field data in Performance_Monitoring_Manager. | |
| 340 | + 'inp' => [ | |
| 341 | + 'name' => 'Interaction to Next Paint', | |
| 342 | + 'value' => round( | |
| 343 | + $audits['interaction-to-next-paint']['numericValue'] | |
| 344 | + ?? $audits['total-blocking-time']['numericValue'] | |
| 345 | + ?? 0, | |
| 346 | + 4 | |
| 347 | + ), | |
| 348 | + 'score' => ( | |
| 349 | + $audits['interaction-to-next-paint']['score'] | |
| 350 | + ?? $audits['total-blocking-time']['score'] | |
| 351 | + ?? 0 | |
| 352 | + ) * 100, | |
| 162 | 353 | 'unit' => 'ms', |
| 163 | - 'good_threshold' => 100, | |
| 164 | - 'needs_improvement_threshold' => 300, | |
| 165 | - 'description' => 'Time from first user interaction to browser response' | |
| 354 | + 'good_threshold' => 200, | |
| 355 | + 'needs_improvement_threshold' => 500, | |
| 356 | + 'description' => 'Responsiveness across all interactions on the page', | |
| 357 | + 'is_lab_proxy' => !isset($audits['interaction-to-next-paint']) | |
| 166 | 358 | ], |
| 167 | 359 | 'cls' => [ |
| 168 | 360 | 'name' => 'Cumulative Layout Shift', |
| 169 | 361 | 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4), |
| @@ -251,10 +443,9 @@ | ||
| 251 | 443 | 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'], |
| 252 | 444 | 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'], |
| 253 | 445 | 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'], |
| 254 | 446 | 'speed-index' => ['title' => 'Speed Index', 'impact' => 'Performance'], |
| 255 | - 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'Performance'], | |
| 256 | - 'max-potential-fid' => ['title' => 'Max Potential First Input Delay', 'impact' => 'FID'], | |
| 447 | + 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'INP'], | |
| 257 | 448 | 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'], |
| 258 | 449 | 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'], |
| 259 | 450 | 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'], |
| 260 | 451 | 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'], |