PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / integrations / class-google-pagespeed-client.php

class-google-pagespeed-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.0, at includes/integrations/class-google-pagespeed-client.php

507 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Google PageSpeed Insights Client Class
4 *
5 * Handles communication with Google PageSpeed Insights API for Core Web Vitals
6 * and performance data retrieval. Extends the base Google API client with
7 * PageSpeed-specific functionality and rate limiting.
8 *
9 * @package ThinkRank\Integrations
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Integrations;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Google PageSpeed Insights Client Class
24 *
25 * Single Responsibility: Handle PageSpeed Insights API communication
26 * Following ThinkRank HTTP client patterns from Claude_Client and OpenAI_Client
27 *
28 * @since 1.0.0
29 */
30 class Google_PageSpeed_Client extends Google_API_Base_Client {
31
32 /**
33 * PageSpeed Insights API base URL
34 */
35 private const API_BASE_URL = 'https://www.googleapis.com/pagespeedonline/v5';
36
37 /**
38 * Rate limit transient key prefix
39 * Following ThinkRank option naming patterns
40 */
41 private const RATE_LIMIT_KEY = 'thinkrank_pagespeed_rate_limit';
42
43 /**
44 * Maximum requests per day (Google free tier limit)
45 */
46 private const MAX_REQUESTS_PER_DAY = 25000;
47
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 * Run PageSpeed test for a URL
116 *
117 * @param string $url URL to test
118 * @param string $strategy Device strategy ('mobile' or 'desktop')
119 * @param array $categories Categories to test (default: ['performance'])
120 * @return array PageSpeed test results
121 * @throws \Exception If API request fails
122 */
123 public function run_pagespeed_test(string $url, string $strategy = 'mobile', array $categories = ['performance']): array {
124 $endpoint = '/runPagespeed';
125 $params = [
126 'url' => $url,
127 'strategy' => $strategy,
128 // http_build_query would serialize an array as category[0]=…,
129 // which the PSI API ignores; a single category must be a scalar.
130 'category' => count($categories) === 1 ? $categories[0] : $categories,
131 ];
132
133 $full_url = self::API_BASE_URL . $endpoint;
134 return $this->make_request($full_url, $params, 'GET');
135 }
136
137 /**
138 * Get a parsed PageSpeed snapshot for a URL, from cache when possible.
139 *
140 * One live Lighthouse run produces Core Web Vitals, opportunities,
141 * diagnostics and the performance score together; callers that previously
142 * triggered separate runs for each now share a single cached result.
143 * Failures are remembered briefly (FAILURE_TTL) so a broken URL doesn't
144 * re-block every request for the full HTTP timeout.
145 *
146 * @param string $url URL to analyze
147 * @param string $strategy Device strategy ('mobile' or 'desktop')
148 * @return array{core_web_vitals:array,opportunities:array,diagnostics:array,performance_score:float,fetched_at:int}
149 * @throws \Exception If the API request fails (including remembered recent failures)
150 */
151 public function get_pagespeed_snapshot(string $url, string $strategy = 'mobile', bool $fresh = false): array {
152 $memo_key = $url . '|' . $strategy;
153 $hash = md5($memo_key);
154
155 // A user-initiated refresh must actually re-measure. The 7-day gate in
156 // Performance_Data_Collector was the only thing $force skipped, so a
157 // manual retry within FAILURE_TTL of any failure re-threw the remembered
158 // message in milliseconds without contacting Google — which made
159 // "refresh" useless for exactly the case people press it in, right after
160 // seeing an error.
161 if ($fresh) {
162 unset(self::$snapshot_memo[$memo_key]);
163 delete_transient('thinkrank_psi_snapshot_' . $hash);
164 delete_transient('thinkrank_psi_failure_' . $hash);
165 }
166
167 if (isset(self::$snapshot_memo[$memo_key])) {
168 return self::$snapshot_memo[$memo_key];
169 }
170
171 $cached = get_transient('thinkrank_psi_snapshot_' . $hash);
172 if (is_array($cached)) {
173 self::$snapshot_memo[$memo_key] = $cached;
174 return $cached;
175 }
176
177 $recent_failure = get_transient('thinkrank_psi_failure_' . $hash);
178 if (is_string($recent_failure) && $recent_failure !== '') {
179 throw new \Exception($recent_failure, self::CODE_REMEMBERED_FAILURE); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
180 }
181
182 try {
183 $result = $this->run_pagespeed_test($url, $strategy, ['performance']);
184 } catch (\Exception $e) {
185 set_transient('thinkrank_psi_failure_' . $hash, $e->getMessage(), self::FAILURE_TTL);
186 throw $e;
187 }
188
189 $snapshot = [
190 'core_web_vitals' => $this->parse_core_web_vitals($result),
191 'opportunities' => $this->parse_opportunities($result),
192 'diagnostics' => $this->parse_diagnostics($result),
193 'performance_score' => (float) (($result['lighthouseResult']['categories']['performance']['score'] ?? 0) * 100),
194 'loading_experience' => $result['loadingExperience'] ?? [],
195 'fetched_at' => time(),
196 ];
197
198 set_transient('thinkrank_psi_snapshot_' . $hash, $snapshot, self::SNAPSHOT_TTL);
199 self::$snapshot_memo[$memo_key] = $snapshot;
200
201 return $snapshot;
202 }
203
204 /**
205 * Test API connection
206 * Following ThinkRank test_connection patterns from AI clients
207 *
208 * @return array Connection test results
209 */
210 public function test_connection(): array {
211 try {
212 $test_url = home_url();
213 $result = $this->run_pagespeed_test($test_url, 'mobile', ['performance']);
214
215 $performance_score = 0;
216 if (isset($result['lighthouseResult']['categories']['performance']['score'])) {
217 $performance_score = $result['lighthouseResult']['categories']['performance']['score'] * 100;
218 }
219
220 return [
221 'success' => true,
222 'message' => 'PageSpeed Insights API connection successful',
223 'test_url' => $test_url,
224 'performance_score' => $performance_score
225 ];
226 } catch (\Exception $e) {
227 return [
228 'success' => false,
229 'error' => $e->getMessage()
230 ];
231 }
232 }
233
234 /**
235 * Get Core Web Vitals data for a URL
236 *
237 * @param string $url URL to analyze
238 * @param string $strategy Device strategy ('mobile' or 'desktop')
239 * @return array Core Web Vitals data
240 * @throws \Exception If API request fails
241 */
242 public function get_core_web_vitals(string $url, string $strategy = 'mobile'): array {
243 return $this->get_pagespeed_snapshot($url, $strategy)['core_web_vitals'];
244 }
245
246 /**
247 * Get performance opportunities for a URL
248 *
249 * @param string $url URL to test
250 * @param string $strategy Testing strategy (mobile/desktop)
251 * @return array Performance opportunities
252 * @throws \Exception If API request fails
253 */
254 public function get_opportunities(string $url, string $strategy = 'mobile'): array {
255 return $this->get_pagespeed_snapshot($url, $strategy)['opportunities'];
256 }
257
258 /**
259 * Get diagnostic information for a URL
260 *
261 * @param string $url URL to test
262 * @param string $strategy Testing strategy (mobile/desktop)
263 * @return array Diagnostic information
264 * @throws \Exception If API request fails
265 */
266 public function get_diagnostics(string $url, string $strategy = 'mobile'): array {
267 return $this->get_pagespeed_snapshot($url, $strategy)['diagnostics'];
268 }
269
270 /**
271 * Parse Core Web Vitals from PageSpeed response
272 *
273 * @param array $pagespeed_data Raw PageSpeed API response
274 * @return array Parsed Core Web Vitals data
275 */
276 private function parse_core_web_vitals(array $pagespeed_data): array {
277 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
278
279 return [
280 'lcp' => [
281 'name' => 'Largest Contentful Paint',
282 'value' => round((($audits['largest-contentful-paint']['numericValue'] ?? 0) / 1000), 4),
283 'score' => ($audits['largest-contentful-paint']['score'] ?? 0) * 100,
284 'unit' => 's',
285 'good_threshold' => 2.5,
286 'needs_improvement_threshold' => 4.0,
287 'description' => 'Time until the largest content element is rendered'
288 ],
289 // INP replaced FID as a Core Web Vital in March 2024. INP is a field
290 // metric — a standard PSI navigation run has no interaction to
291 // measure — so Lighthouse only reports it in timespan mode. Read that
292 // audit when it is present and otherwise fall back to Total Blocking
293 // Time, which is Google's documented lab proxy for INP. Real INP
294 // comes from the CrUX field data in Performance_Monitoring_Manager.
295 'inp' => [
296 'name' => 'Interaction to Next Paint',
297 'value' => round(
298 $audits['interaction-to-next-paint']['numericValue']
299 ?? $audits['total-blocking-time']['numericValue']
300 ?? 0,
301 4
302 ),
303 'score' => (
304 $audits['interaction-to-next-paint']['score']
305 ?? $audits['total-blocking-time']['score']
306 ?? 0
307 ) * 100,
308 'unit' => 'ms',
309 'good_threshold' => 200,
310 'needs_improvement_threshold' => 500,
311 'description' => 'Responsiveness across all interactions on the page',
312 'is_lab_proxy' => !isset($audits['interaction-to-next-paint'])
313 ],
314 'cls' => [
315 'name' => 'Cumulative Layout Shift',
316 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4),
317 'score' => ($audits['cumulative-layout-shift']['score'] ?? 0) * 100,
318 'unit' => '',
319 'good_threshold' => 0.1,
320 'needs_improvement_threshold' => 0.25,
321 'description' => 'Measure of visual stability during page load'
322 ],
323 'fcp' => [
324 'name' => 'First Contentful Paint',
325 'value' => round((($audits['first-contentful-paint']['numericValue'] ?? 0) / 1000), 4),
326 'score' => ($audits['first-contentful-paint']['score'] ?? 0) * 100,
327 'unit' => 's',
328 'good_threshold' => 1.8,
329 'needs_improvement_threshold' => 3.0,
330 'description' => 'Time until the first content is painted on screen'
331 ]
332 ];
333 }
334
335 /**
336 * Parse performance opportunities from PageSpeed data
337 *
338 * @param array $pagespeed_data Raw PageSpeed API response
339 * @return array Parsed opportunities data
340 */
341 private function parse_opportunities(array $pagespeed_data): array {
342 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
343 $opportunities = [];
344
345 // Define opportunity audits that provide savings estimates
346 $opportunity_audits = [
347 'render-blocking-resources' => 'Eliminate render-blocking resources',
348 'unused-css-rules' => 'Remove unused CSS',
349 'unused-javascript' => 'Remove unused JavaScript',
350 'modern-image-formats' => 'Serve images in next-gen formats',
351 'offscreen-images' => 'Defer offscreen images',
352 'unminified-css' => 'Minify CSS',
353 'unminified-javascript' => 'Minify JavaScript',
354 'efficient-animated-content' => 'Use video formats for animated content',
355 'duplicated-javascript' => 'Remove duplicate modules in JavaScript bundles',
356 'legacy-javascript' => 'Avoid serving legacy JavaScript to modern browsers'
357 ];
358
359 foreach ($opportunity_audits as $audit_id => $title) {
360 if (isset($audits[$audit_id]) && isset($audits[$audit_id]['details'])) {
361 $audit = $audits[$audit_id];
362 $savings = $audit['details']['overallSavingsMs'] ?? 0;
363
364 if ($savings > 0) {
365 $opportunities[] = [
366 'id' => $audit_id,
367 'title' => $title,
368 'description' => $audit['description'] ?? '',
369 'estimated_savings' => $savings,
370 'score' => ($audit['score'] ?? 0) * 100,
371 'details' => $audit['details'] ?? [],
372 'difficulty' => $this->get_difficulty_level($audit_id)
373 ];
374 }
375 }
376 }
377
378 // Sort by estimated savings (highest first)
379 usort($opportunities, function($a, $b) {
380 return $b['estimated_savings'] - $a['estimated_savings'];
381 });
382
383 return $opportunities;
384 }
385
386 /**
387 * Parse diagnostic information from PageSpeed data
388 *
389 * @param array $pagespeed_data Raw PageSpeed API response
390 * @return array Parsed diagnostics data
391 */
392 private function parse_diagnostics(array $pagespeed_data): array {
393 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
394 $diagnostics = [];
395
396 // Define diagnostic audits
397 $diagnostic_audits = [
398 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'],
399 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'],
400 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'],
401 'speed-index' => ['title' => 'Speed Index', 'impact' => 'Performance'],
402 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'INP'],
403 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'],
404 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'],
405 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'],
406 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'],
407 'critical-request-chains' => ['title' => 'Avoid chaining critical requests', 'impact' => 'Performance'],
408 'redirects' => ['title' => 'Avoid multiple page redirects', 'impact' => 'Performance'],
409 'installable-manifest' => ['title' => 'Web app manifest meets the installability requirements', 'impact' => 'PWA'],
410 'apple-touch-icon' => ['title' => 'Provides a valid apple-touch-icon', 'impact' => 'PWA'],
411 'splash-screen' => ['title' => 'Configured for a custom splash screen', 'impact' => 'PWA'],
412 'themed-omnibox' => ['title' => 'Sets a theme color for the address bar', 'impact' => 'PWA'],
413 'content-width' => ['title' => 'Content is sized correctly for the viewport', 'impact' => 'Mobile'],
414 'image-aspect-ratio' => ['title' => 'Displays images with correct aspect ratio', 'impact' => 'Layout'],
415 'image-size-responsive' => ['title' => 'Serves images with appropriate resolution', 'impact' => 'Performance'],
416 'preload-fonts' => ['title' => 'Fonts with font-display: optional are preloaded', 'impact' => 'Performance'],
417 'font-display' => ['title' => 'All text remains visible during webfont loads', 'impact' => 'Performance']
418 ];
419
420 foreach ($diagnostic_audits as $audit_id => $config) {
421 if (isset($audits[$audit_id])) {
422 $audit = $audits[$audit_id];
423 $score = $audit['score'] ?? null;
424
425 $status = 'info';
426 if ($score !== null) {
427 if ($score >= 0.9) {
428 $status = 'passed';
429 } elseif ($score >= 0.5) {
430 $status = 'warning';
431 } else {
432 $status = 'failed';
433 }
434 }
435
436 $diagnostics[] = [
437 'id' => $audit_id,
438 'title' => $config['title'],
439 'description' => $audit['description'] ?? '',
440 'status' => $status,
441 'score' => $score ? ($score * 100) : null,
442 'impact' => $config['impact'],
443 'details' => $audit['details'] ?? [],
444 'display_value' => $audit['displayValue'] ?? null
445 ];
446 }
447 }
448
449 return $diagnostics;
450 }
451
452 /**
453 * Get difficulty level for optimization opportunities
454 *
455 * @param string $audit_id Audit identifier
456 * @return string Difficulty level
457 */
458 private function get_difficulty_level(string $audit_id): string {
459 $difficulty_map = [
460 'unminified-css' => 'Easy',
461 'unminified-javascript' => 'Easy',
462 'modern-image-formats' => 'Easy',
463 'offscreen-images' => 'Medium',
464 'unused-css-rules' => 'Hard',
465 'unused-javascript' => 'Hard',
466 'render-blocking-resources' => 'Medium',
467 'efficient-animated-content' => 'Medium',
468 'duplicated-javascript' => 'Hard',
469 'legacy-javascript' => 'Medium'
470 ];
471
472 return $difficulty_map[$audit_id] ?? 'Medium';
473 }
474
475 /**
476 * Get rate limit configuration
477 * Following ThinkRank rate limiting patterns
478 *
479 * @return array Rate limit configuration
480 */
481 protected function get_rate_limits(): array {
482 return [
483 'max_requests_per_day' => self::MAX_REQUESTS_PER_DAY,
484 'reset_time' => get_transient(self::RATE_LIMIT_KEY . '_reset') ?: strtotime('tomorrow')
485 ];
486 }
487
488 /**
489 * Get rate limit transient key
490 * Following ThinkRank option naming patterns
491 *
492 * @return string Rate limit key
493 */
494 protected function get_rate_limit_key(): string {
495 return self::RATE_LIMIT_KEY;
496 }
497
498 /**
499 * Get rate limit error message
500 *
501 * @return string Error message
502 */
503 protected function get_rate_limit_error_message(): string {
504 return 'PageSpeed Insights API rate limit exceeded. Try again tomorrow.';
505 }
506 }
507