← All changes
|
includes/integrations/class-google-pagespeed-client.php
+93
-13
1.29.0
→
2.7.0
View file →
| @@ -59,8 +59,15 @@ | ||
| 59 | 59 | */ |
| 60 | 60 | private const FAILURE_TTL = 300; |
| 61 | 61 | |
| 62 | 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 | + /** | |
| 63 | 70 | * Per-request memo of parsed snapshots, keyed by url|strategy |
| 64 | 71 | * |
| 65 | 72 | * @var array<string,array> |
| 66 | 73 | */ |
| @@ -104,8 +111,34 @@ | ||
| 104 | 111 | return new self('', $timeout ?? self::DEFAULT_TIMEOUT, $access_token !== '' ? $access_token : null); |
| 105 | 112 | } |
| 106 | 113 | |
| 107 | 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 | + /** | |
| 108 | 141 | * Run PageSpeed test for a URL |
| 109 | 142 | * |
| 110 | 143 | * @param string $url URL to test |
| 111 | 144 | * @param string $strategy Device strategy ('mobile' or 'desktop') |
| @@ -140,15 +173,28 @@ | ||
| 140 | 173 | * @param string $strategy Device strategy ('mobile' or 'desktop') |
| 141 | 174 | * @return array{core_web_vitals:array,opportunities:array,diagnostics:array,performance_score:float,fetched_at:int} |
| 142 | 175 | * @throws \Exception If the API request fails (including remembered recent failures) |
| 143 | 176 | */ |
| 144 | - public function get_pagespeed_snapshot(string $url, string $strategy = 'mobile'): array { | |
| 177 | + public function get_pagespeed_snapshot(string $url, string $strategy = 'mobile', bool $fresh = false): array { | |
| 145 | 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 | + | |
| 146 | 193 | if (isset(self::$snapshot_memo[$memo_key])) { |
| 147 | 194 | return self::$snapshot_memo[$memo_key]; |
| 148 | 195 | } |
| 149 | 196 | |
| 150 | - $hash = md5($memo_key); | |
| 151 | 197 | $cached = get_transient('thinkrank_psi_snapshot_' . $hash); |
| 152 | 198 | if (is_array($cached)) { |
| 153 | 199 | self::$snapshot_memo[$memo_key] = $cached; |
| 154 | 200 | return $cached; |
| @@ -155,9 +201,9 @@ | ||
| 155 | 201 | } |
| 156 | 202 | |
| 157 | 203 | $recent_failure = get_transient('thinkrank_psi_failure_' . $hash); |
| 158 | 204 | if (is_string($recent_failure) && $recent_failure !== '') { |
| 159 | - throw new \Exception($recent_failure); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped | |
| 205 | + throw new \Exception($recent_failure, self::CODE_REMEMBERED_FAILURE); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped | |
| 160 | 206 | } |
| 161 | 207 | |
| 162 | 208 | try { |
| 163 | 209 | $result = $this->run_pagespeed_test($url, $strategy, ['performance']); |
| @@ -165,13 +211,32 @@ | ||
| 165 | 211 | set_transient('thinkrank_psi_failure_' . $hash, $e->getMessage(), self::FAILURE_TTL); |
| 166 | 212 | throw $e; |
| 167 | 213 | } |
| 168 | 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 | + | |
| 169 | 234 | $snapshot = [ |
| 170 | 235 | 'core_web_vitals' => $this->parse_core_web_vitals($result), |
| 171 | 236 | 'opportunities' => $this->parse_opportunities($result), |
| 172 | 237 | 'diagnostics' => $this->parse_diagnostics($result), |
| 173 | - 'performance_score' => (float) (($result['lighthouseResult']['categories']['performance']['score'] ?? 0) * 100), | |
| 238 | + 'performance_score' => (float) ($raw_score * 100), | |
| 174 | 239 | 'loading_experience' => $result['loadingExperience'] ?? [], |
| 175 | 240 | 'fetched_at' => time(), |
| 176 | 241 | ]; |
| 177 | 242 | |
| @@ -265,16 +330,32 @@ | ||
| 265 | 330 | 'good_threshold' => 2.5, |
| 266 | 331 | 'needs_improvement_threshold' => 4.0, |
| 267 | 332 | 'description' => 'Time until the largest content element is rendered' |
| 268 | 333 | ], |
| 269 | - 'fid' => [ | |
| 270 | - 'name' => 'First Input Delay', | |
| 271 | - 'value' => round(($audits['max-potential-fid']['numericValue'] ?? 0), 4), | |
| 272 | - '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, | |
| 273 | 353 | 'unit' => 'ms', |
| 274 | - 'good_threshold' => 100, | |
| 275 | - 'needs_improvement_threshold' => 300, | |
| 276 | - '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']) | |
| 277 | 358 | ], |
| 278 | 359 | 'cls' => [ |
| 279 | 360 | 'name' => 'Cumulative Layout Shift', |
| 280 | 361 | 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4), |
| @@ -362,10 +443,9 @@ | ||
| 362 | 443 | 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'], |
| 363 | 444 | 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'], |
| 364 | 445 | 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'], |
| 365 | 446 | 'speed-index' => ['title' => 'Speed Index', 'impact' => 'Performance'], |
| 366 | - 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'Performance'], | |
| 367 | - 'max-potential-fid' => ['title' => 'Max Potential First Input Delay', 'impact' => 'FID'], | |
| 447 | + 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'INP'], | |
| 368 | 448 | 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'], |
| 369 | 449 | 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'], |
| 370 | 450 | 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'], |
| 371 | 451 | 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'], |