PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.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 1.28.0, at includes/integrations/class-google-pagespeed-client.php

472 lines 18.8 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 * Per-request memo of parsed snapshots, keyed by url|strategy
64 *
65 * @var array<string,array>
66 */
67 private static array $snapshot_memo = [];
68
69 /**
70 * Default HTTP timeout for PageSpeed runs (seconds).
71 *
72 * Real Lighthouse runs routinely take 15-45s; the previous 20s timeout
73 * aborted a large share of otherwise-successful runs.
74 */
75 public const DEFAULT_TIMEOUT = 45;
76
77 /**
78 * Build a client authenticated the way the PageSpeed API expects.
79 *
80 * Auth order (RankMath uses the same model, minus the key):
81 * 1. Site-owned API key — dedicated per-project quota, always reliable.
82 * 2. The user's Google OAuth token — works at low volume; quota is
83 * shared across the OAuth project, so ThinkRank must stay frugal
84 * (see the 7-day refresh gate in Performance_Data_Collector).
85 * 3. Keyless — Google's shared anonymous pool; last resort.
86 *
87 * @param int|null $timeout HTTP timeout in seconds (default self::DEFAULT_TIMEOUT)
88 * @return self
89 */
90 public static function for_site(?int $timeout = null): self {
91 $api_key = '';
92 $access_token = '';
93 if (class_exists('\\ThinkRank\\Core\\Settings')) {
94 $settings = new \ThinkRank\Core\Settings();
95 $api_key = (string) $settings->get('google_pagespeed_api_key', '');
96 $access_token = (string) $settings->get('google_access_token', '');
97 }
98
99 if ($api_key !== '') {
100 // A dedicated key wins: pass no token so quota bills the key's project.
101 return new self($api_key, $timeout ?? self::DEFAULT_TIMEOUT, null);
102 }
103
104 return new self('', $timeout ?? self::DEFAULT_TIMEOUT, $access_token !== '' ? $access_token : null);
105 }
106
107 /**
108 * Run PageSpeed test for a URL
109 *
110 * @param string $url URL to test
111 * @param string $strategy Device strategy ('mobile' or 'desktop')
112 * @param array $categories Categories to test (default: ['performance'])
113 * @return array PageSpeed test results
114 * @throws \Exception If API request fails
115 */
116 public function run_pagespeed_test(string $url, string $strategy = 'mobile', array $categories = ['performance']): array {
117 $endpoint = '/runPagespeed';
118 $params = [
119 'url' => $url,
120 'strategy' => $strategy,
121 // http_build_query would serialize an array as category[0]=…,
122 // which the PSI API ignores; a single category must be a scalar.
123 'category' => count($categories) === 1 ? $categories[0] : $categories,
124 ];
125
126 $full_url = self::API_BASE_URL . $endpoint;
127 return $this->make_request($full_url, $params, 'GET');
128 }
129
130 /**
131 * Get a parsed PageSpeed snapshot for a URL, from cache when possible.
132 *
133 * One live Lighthouse run produces Core Web Vitals, opportunities,
134 * diagnostics and the performance score together; callers that previously
135 * triggered separate runs for each now share a single cached result.
136 * Failures are remembered briefly (FAILURE_TTL) so a broken URL doesn't
137 * re-block every request for the full HTTP timeout.
138 *
139 * @param string $url URL to analyze
140 * @param string $strategy Device strategy ('mobile' or 'desktop')
141 * @return array{core_web_vitals:array,opportunities:array,diagnostics:array,performance_score:float,fetched_at:int}
142 * @throws \Exception If the API request fails (including remembered recent failures)
143 */
144 public function get_pagespeed_snapshot(string $url, string $strategy = 'mobile'): array {
145 $memo_key = $url . '|' . $strategy;
146 if (isset(self::$snapshot_memo[$memo_key])) {
147 return self::$snapshot_memo[$memo_key];
148 }
149
150 $hash = md5($memo_key);
151 $cached = get_transient('thinkrank_psi_snapshot_' . $hash);
152 if (is_array($cached)) {
153 self::$snapshot_memo[$memo_key] = $cached;
154 return $cached;
155 }
156
157 $recent_failure = get_transient('thinkrank_psi_failure_' . $hash);
158 if (is_string($recent_failure) && $recent_failure !== '') {
159 throw new \Exception($recent_failure); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
160 }
161
162 try {
163 $result = $this->run_pagespeed_test($url, $strategy, ['performance']);
164 } catch (\Exception $e) {
165 set_transient('thinkrank_psi_failure_' . $hash, $e->getMessage(), self::FAILURE_TTL);
166 throw $e;
167 }
168
169 $snapshot = [
170 'core_web_vitals' => $this->parse_core_web_vitals($result),
171 'opportunities' => $this->parse_opportunities($result),
172 'diagnostics' => $this->parse_diagnostics($result),
173 'performance_score' => (float) (($result['lighthouseResult']['categories']['performance']['score'] ?? 0) * 100),
174 'loading_experience' => $result['loadingExperience'] ?? [],
175 'fetched_at' => time(),
176 ];
177
178 set_transient('thinkrank_psi_snapshot_' . $hash, $snapshot, self::SNAPSHOT_TTL);
179 self::$snapshot_memo[$memo_key] = $snapshot;
180
181 return $snapshot;
182 }
183
184 /**
185 * Test API connection
186 * Following ThinkRank test_connection patterns from AI clients
187 *
188 * @return array Connection test results
189 */
190 public function test_connection(): array {
191 try {
192 $test_url = home_url();
193 $result = $this->run_pagespeed_test($test_url, 'mobile', ['performance']);
194
195 $performance_score = 0;
196 if (isset($result['lighthouseResult']['categories']['performance']['score'])) {
197 $performance_score = $result['lighthouseResult']['categories']['performance']['score'] * 100;
198 }
199
200 return [
201 'success' => true,
202 'message' => 'PageSpeed Insights API connection successful',
203 'test_url' => $test_url,
204 'performance_score' => $performance_score
205 ];
206 } catch (\Exception $e) {
207 return [
208 'success' => false,
209 'error' => $e->getMessage()
210 ];
211 }
212 }
213
214 /**
215 * Get Core Web Vitals data for a URL
216 *
217 * @param string $url URL to analyze
218 * @param string $strategy Device strategy ('mobile' or 'desktop')
219 * @return array Core Web Vitals data
220 * @throws \Exception If API request fails
221 */
222 public function get_core_web_vitals(string $url, string $strategy = 'mobile'): array {
223 return $this->get_pagespeed_snapshot($url, $strategy)['core_web_vitals'];
224 }
225
226 /**
227 * Get performance opportunities for a URL
228 *
229 * @param string $url URL to test
230 * @param string $strategy Testing strategy (mobile/desktop)
231 * @return array Performance opportunities
232 * @throws \Exception If API request fails
233 */
234 public function get_opportunities(string $url, string $strategy = 'mobile'): array {
235 return $this->get_pagespeed_snapshot($url, $strategy)['opportunities'];
236 }
237
238 /**
239 * Get diagnostic information for a URL
240 *
241 * @param string $url URL to test
242 * @param string $strategy Testing strategy (mobile/desktop)
243 * @return array Diagnostic information
244 * @throws \Exception If API request fails
245 */
246 public function get_diagnostics(string $url, string $strategy = 'mobile'): array {
247 return $this->get_pagespeed_snapshot($url, $strategy)['diagnostics'];
248 }
249
250 /**
251 * Parse Core Web Vitals from PageSpeed response
252 *
253 * @param array $pagespeed_data Raw PageSpeed API response
254 * @return array Parsed Core Web Vitals data
255 */
256 private function parse_core_web_vitals(array $pagespeed_data): array {
257 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
258
259 return [
260 'lcp' => [
261 'name' => 'Largest Contentful Paint',
262 'value' => round((($audits['largest-contentful-paint']['numericValue'] ?? 0) / 1000), 4),
263 'score' => ($audits['largest-contentful-paint']['score'] ?? 0) * 100,
264 'unit' => 's',
265 'good_threshold' => 2.5,
266 'needs_improvement_threshold' => 4.0,
267 'description' => 'Time until the largest content element is rendered'
268 ],
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,
273 'unit' => 'ms',
274 'good_threshold' => 100,
275 'needs_improvement_threshold' => 300,
276 'description' => 'Time from first user interaction to browser response'
277 ],
278 'cls' => [
279 'name' => 'Cumulative Layout Shift',
280 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4),
281 'score' => ($audits['cumulative-layout-shift']['score'] ?? 0) * 100,
282 'unit' => '',
283 'good_threshold' => 0.1,
284 'needs_improvement_threshold' => 0.25,
285 'description' => 'Measure of visual stability during page load'
286 ],
287 'fcp' => [
288 'name' => 'First Contentful Paint',
289 'value' => round((($audits['first-contentful-paint']['numericValue'] ?? 0) / 1000), 4),
290 'score' => ($audits['first-contentful-paint']['score'] ?? 0) * 100,
291 'unit' => 's',
292 'good_threshold' => 1.8,
293 'needs_improvement_threshold' => 3.0,
294 'description' => 'Time until the first content is painted on screen'
295 ]
296 ];
297 }
298
299 /**
300 * Parse performance opportunities from PageSpeed data
301 *
302 * @param array $pagespeed_data Raw PageSpeed API response
303 * @return array Parsed opportunities data
304 */
305 private function parse_opportunities(array $pagespeed_data): array {
306 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
307 $opportunities = [];
308
309 // Define opportunity audits that provide savings estimates
310 $opportunity_audits = [
311 'render-blocking-resources' => 'Eliminate render-blocking resources',
312 'unused-css-rules' => 'Remove unused CSS',
313 'unused-javascript' => 'Remove unused JavaScript',
314 'modern-image-formats' => 'Serve images in next-gen formats',
315 'offscreen-images' => 'Defer offscreen images',
316 'unminified-css' => 'Minify CSS',
317 'unminified-javascript' => 'Minify JavaScript',
318 'efficient-animated-content' => 'Use video formats for animated content',
319 'duplicated-javascript' => 'Remove duplicate modules in JavaScript bundles',
320 'legacy-javascript' => 'Avoid serving legacy JavaScript to modern browsers'
321 ];
322
323 foreach ($opportunity_audits as $audit_id => $title) {
324 if (isset($audits[$audit_id]) && isset($audits[$audit_id]['details'])) {
325 $audit = $audits[$audit_id];
326 $savings = $audit['details']['overallSavingsMs'] ?? 0;
327
328 if ($savings > 0) {
329 $opportunities[] = [
330 'id' => $audit_id,
331 'title' => $title,
332 'description' => $audit['description'] ?? '',
333 'estimated_savings' => $savings,
334 'score' => ($audit['score'] ?? 0) * 100,
335 'details' => $audit['details'] ?? [],
336 'difficulty' => $this->get_difficulty_level($audit_id)
337 ];
338 }
339 }
340 }
341
342 // Sort by estimated savings (highest first)
343 usort($opportunities, function($a, $b) {
344 return $b['estimated_savings'] - $a['estimated_savings'];
345 });
346
347 return $opportunities;
348 }
349
350 /**
351 * Parse diagnostic information from PageSpeed data
352 *
353 * @param array $pagespeed_data Raw PageSpeed API response
354 * @return array Parsed diagnostics data
355 */
356 private function parse_diagnostics(array $pagespeed_data): array {
357 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
358 $diagnostics = [];
359
360 // Define diagnostic audits
361 $diagnostic_audits = [
362 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'],
363 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'],
364 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'],
365 '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'],
368 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'],
369 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'],
370 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'],
371 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'],
372 'critical-request-chains' => ['title' => 'Avoid chaining critical requests', 'impact' => 'Performance'],
373 'redirects' => ['title' => 'Avoid multiple page redirects', 'impact' => 'Performance'],
374 'installable-manifest' => ['title' => 'Web app manifest meets the installability requirements', 'impact' => 'PWA'],
375 'apple-touch-icon' => ['title' => 'Provides a valid apple-touch-icon', 'impact' => 'PWA'],
376 'splash-screen' => ['title' => 'Configured for a custom splash screen', 'impact' => 'PWA'],
377 'themed-omnibox' => ['title' => 'Sets a theme color for the address bar', 'impact' => 'PWA'],
378 'content-width' => ['title' => 'Content is sized correctly for the viewport', 'impact' => 'Mobile'],
379 'image-aspect-ratio' => ['title' => 'Displays images with correct aspect ratio', 'impact' => 'Layout'],
380 'image-size-responsive' => ['title' => 'Serves images with appropriate resolution', 'impact' => 'Performance'],
381 'preload-fonts' => ['title' => 'Fonts with font-display: optional are preloaded', 'impact' => 'Performance'],
382 'font-display' => ['title' => 'All text remains visible during webfont loads', 'impact' => 'Performance']
383 ];
384
385 foreach ($diagnostic_audits as $audit_id => $config) {
386 if (isset($audits[$audit_id])) {
387 $audit = $audits[$audit_id];
388 $score = $audit['score'] ?? null;
389
390 $status = 'info';
391 if ($score !== null) {
392 if ($score >= 0.9) {
393 $status = 'passed';
394 } elseif ($score >= 0.5) {
395 $status = 'warning';
396 } else {
397 $status = 'failed';
398 }
399 }
400
401 $diagnostics[] = [
402 'id' => $audit_id,
403 'title' => $config['title'],
404 'description' => $audit['description'] ?? '',
405 'status' => $status,
406 'score' => $score ? ($score * 100) : null,
407 'impact' => $config['impact'],
408 'details' => $audit['details'] ?? [],
409 'display_value' => $audit['displayValue'] ?? null
410 ];
411 }
412 }
413
414 return $diagnostics;
415 }
416
417 /**
418 * Get difficulty level for optimization opportunities
419 *
420 * @param string $audit_id Audit identifier
421 * @return string Difficulty level
422 */
423 private function get_difficulty_level(string $audit_id): string {
424 $difficulty_map = [
425 'unminified-css' => 'Easy',
426 'unminified-javascript' => 'Easy',
427 'modern-image-formats' => 'Easy',
428 'offscreen-images' => 'Medium',
429 'unused-css-rules' => 'Hard',
430 'unused-javascript' => 'Hard',
431 'render-blocking-resources' => 'Medium',
432 'efficient-animated-content' => 'Medium',
433 'duplicated-javascript' => 'Hard',
434 'legacy-javascript' => 'Medium'
435 ];
436
437 return $difficulty_map[$audit_id] ?? 'Medium';
438 }
439
440 /**
441 * Get rate limit configuration
442 * Following ThinkRank rate limiting patterns
443 *
444 * @return array Rate limit configuration
445 */
446 protected function get_rate_limits(): array {
447 return [
448 'max_requests_per_day' => self::MAX_REQUESTS_PER_DAY,
449 'reset_time' => get_transient(self::RATE_LIMIT_KEY . '_reset') ?: strtotime('tomorrow')
450 ];
451 }
452
453 /**
454 * Get rate limit transient key
455 * Following ThinkRank option naming patterns
456 *
457 * @return string Rate limit key
458 */
459 protected function get_rate_limit_key(): string {
460 return self::RATE_LIMIT_KEY;
461 }
462
463 /**
464 * Get rate limit error message
465 *
466 * @return string Error message
467 */
468 protected function get_rate_limit_error_message(): string {
469 return 'PageSpeed Insights API rate limit exceeded. Try again tomorrow.';
470 }
471 }
472