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

552 lines 22.7 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 * 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 /**
141 * Run PageSpeed test for a URL
142 *
143 * @param string $url URL to test
144 * @param string $strategy Device strategy ('mobile' or 'desktop')
145 * @param array $categories Categories to test (default: ['performance'])
146 * @return array PageSpeed test results
147 * @throws \Exception If API request fails
148 */
149 public function run_pagespeed_test(string $url, string $strategy = 'mobile', array $categories = ['performance']): array {
150 $endpoint = '/runPagespeed';
151 $params = [
152 'url' => $url,
153 'strategy' => $strategy,
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,
157 ];
158
159 $full_url = self::API_BASE_URL . $endpoint;
160 return $this->make_request($full_url, $params, 'GET');
161 }
162
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 /**
250 * Test API connection
251 * Following ThinkRank test_connection patterns from AI clients
252 *
253 * @return array Connection test results
254 */
255 public function test_connection(): array {
256 try {
257 $test_url = home_url();
258 $result = $this->run_pagespeed_test($test_url, 'mobile', ['performance']);
259
260 $performance_score = 0;
261 if (isset($result['lighthouseResult']['categories']['performance']['score'])) {
262 $performance_score = $result['lighthouseResult']['categories']['performance']['score'] * 100;
263 }
264
265 return [
266 'success' => true,
267 'message' => 'PageSpeed Insights API connection successful',
268 'test_url' => $test_url,
269 'performance_score' => $performance_score
270 ];
271 } catch (\Exception $e) {
272 return [
273 'success' => false,
274 'error' => $e->getMessage()
275 ];
276 }
277 }
278
279 /**
280 * Get Core Web Vitals data for a URL
281 *
282 * @param string $url URL to analyze
283 * @param string $strategy Device strategy ('mobile' or 'desktop')
284 * @return array Core Web Vitals data
285 * @throws \Exception If API request fails
286 */
287 public function get_core_web_vitals(string $url, string $strategy = 'mobile'): array {
288 return $this->get_pagespeed_snapshot($url, $strategy)['core_web_vitals'];
289 }
290
291 /**
292 * Get performance opportunities for a URL
293 *
294 * @param string $url URL to test
295 * @param string $strategy Testing strategy (mobile/desktop)
296 * @return array Performance opportunities
297 * @throws \Exception If API request fails
298 */
299 public function get_opportunities(string $url, string $strategy = 'mobile'): array {
300 return $this->get_pagespeed_snapshot($url, $strategy)['opportunities'];
301 }
302
303 /**
304 * Get diagnostic information for a URL
305 *
306 * @param string $url URL to test
307 * @param string $strategy Testing strategy (mobile/desktop)
308 * @return array Diagnostic information
309 * @throws \Exception If API request fails
310 */
311 public function get_diagnostics(string $url, string $strategy = 'mobile'): array {
312 return $this->get_pagespeed_snapshot($url, $strategy)['diagnostics'];
313 }
314
315 /**
316 * Parse Core Web Vitals from PageSpeed response
317 *
318 * @param array $pagespeed_data Raw PageSpeed API response
319 * @return array Parsed Core Web Vitals data
320 */
321 private function parse_core_web_vitals(array $pagespeed_data): array {
322 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
323
324 return [
325 'lcp' => [
326 'name' => 'Largest Contentful Paint',
327 'value' => round((($audits['largest-contentful-paint']['numericValue'] ?? 0) / 1000), 4),
328 'score' => ($audits['largest-contentful-paint']['score'] ?? 0) * 100,
329 'unit' => 's',
330 'good_threshold' => 2.5,
331 'needs_improvement_threshold' => 4.0,
332 'description' => 'Time until the largest content element is rendered'
333 ],
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,
353 'unit' => 'ms',
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'])
358 ],
359 'cls' => [
360 'name' => 'Cumulative Layout Shift',
361 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4),
362 'score' => ($audits['cumulative-layout-shift']['score'] ?? 0) * 100,
363 'unit' => '',
364 'good_threshold' => 0.1,
365 'needs_improvement_threshold' => 0.25,
366 'description' => 'Measure of visual stability during page load'
367 ],
368 'fcp' => [
369 'name' => 'First Contentful Paint',
370 'value' => round((($audits['first-contentful-paint']['numericValue'] ?? 0) / 1000), 4),
371 'score' => ($audits['first-contentful-paint']['score'] ?? 0) * 100,
372 'unit' => 's',
373 'good_threshold' => 1.8,
374 'needs_improvement_threshold' => 3.0,
375 'description' => 'Time until the first content is painted on screen'
376 ]
377 ];
378 }
379
380 /**
381 * Parse performance opportunities from PageSpeed data
382 *
383 * @param array $pagespeed_data Raw PageSpeed API response
384 * @return array Parsed opportunities data
385 */
386 private function parse_opportunities(array $pagespeed_data): array {
387 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
388 $opportunities = [];
389
390 // Define opportunity audits that provide savings estimates
391 $opportunity_audits = [
392 'render-blocking-resources' => 'Eliminate render-blocking resources',
393 'unused-css-rules' => 'Remove unused CSS',
394 'unused-javascript' => 'Remove unused JavaScript',
395 'modern-image-formats' => 'Serve images in next-gen formats',
396 'offscreen-images' => 'Defer offscreen images',
397 'unminified-css' => 'Minify CSS',
398 'unminified-javascript' => 'Minify JavaScript',
399 'efficient-animated-content' => 'Use video formats for animated content',
400 'duplicated-javascript' => 'Remove duplicate modules in JavaScript bundles',
401 'legacy-javascript' => 'Avoid serving legacy JavaScript to modern browsers'
402 ];
403
404 foreach ($opportunity_audits as $audit_id => $title) {
405 if (isset($audits[$audit_id]) && isset($audits[$audit_id]['details'])) {
406 $audit = $audits[$audit_id];
407 $savings = $audit['details']['overallSavingsMs'] ?? 0;
408
409 if ($savings > 0) {
410 $opportunities[] = [
411 'id' => $audit_id,
412 'title' => $title,
413 'description' => $audit['description'] ?? '',
414 'estimated_savings' => $savings,
415 'score' => ($audit['score'] ?? 0) * 100,
416 'details' => $audit['details'] ?? [],
417 'difficulty' => $this->get_difficulty_level($audit_id)
418 ];
419 }
420 }
421 }
422
423 // Sort by estimated savings (highest first)
424 usort($opportunities, function($a, $b) {
425 return $b['estimated_savings'] - $a['estimated_savings'];
426 });
427
428 return $opportunities;
429 }
430
431 /**
432 * Parse diagnostic information from PageSpeed data
433 *
434 * @param array $pagespeed_data Raw PageSpeed API response
435 * @return array Parsed diagnostics data
436 */
437 private function parse_diagnostics(array $pagespeed_data): array {
438 $audits = $pagespeed_data['lighthouseResult']['audits'] ?? [];
439 $diagnostics = [];
440
441 // Define diagnostic audits
442 $diagnostic_audits = [
443 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'],
444 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'],
445 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'],
446 'speed-index' => ['title' => 'Speed Index', 'impact' => 'Performance'],
447 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'INP'],
448 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'],
449 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'],
450 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'],
451 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'],
452 'critical-request-chains' => ['title' => 'Avoid chaining critical requests', 'impact' => 'Performance'],
453 'redirects' => ['title' => 'Avoid multiple page redirects', 'impact' => 'Performance'],
454 'installable-manifest' => ['title' => 'Web app manifest meets the installability requirements', 'impact' => 'PWA'],
455 'apple-touch-icon' => ['title' => 'Provides a valid apple-touch-icon', 'impact' => 'PWA'],
456 'splash-screen' => ['title' => 'Configured for a custom splash screen', 'impact' => 'PWA'],
457 'themed-omnibox' => ['title' => 'Sets a theme color for the address bar', 'impact' => 'PWA'],
458 'content-width' => ['title' => 'Content is sized correctly for the viewport', 'impact' => 'Mobile'],
459 'image-aspect-ratio' => ['title' => 'Displays images with correct aspect ratio', 'impact' => 'Layout'],
460 'image-size-responsive' => ['title' => 'Serves images with appropriate resolution', 'impact' => 'Performance'],
461 'preload-fonts' => ['title' => 'Fonts with font-display: optional are preloaded', 'impact' => 'Performance'],
462 'font-display' => ['title' => 'All text remains visible during webfont loads', 'impact' => 'Performance']
463 ];
464
465 foreach ($diagnostic_audits as $audit_id => $config) {
466 if (isset($audits[$audit_id])) {
467 $audit = $audits[$audit_id];
468 $score = $audit['score'] ?? null;
469
470 $status = 'info';
471 if ($score !== null) {
472 if ($score >= 0.9) {
473 $status = 'passed';
474 } elseif ($score >= 0.5) {
475 $status = 'warning';
476 } else {
477 $status = 'failed';
478 }
479 }
480
481 $diagnostics[] = [
482 'id' => $audit_id,
483 'title' => $config['title'],
484 'description' => $audit['description'] ?? '',
485 'status' => $status,
486 'score' => $score ? ($score * 100) : null,
487 'impact' => $config['impact'],
488 'details' => $audit['details'] ?? [],
489 'display_value' => $audit['displayValue'] ?? null
490 ];
491 }
492 }
493
494 return $diagnostics;
495 }
496
497 /**
498 * Get difficulty level for optimization opportunities
499 *
500 * @param string $audit_id Audit identifier
501 * @return string Difficulty level
502 */
503 private function get_difficulty_level(string $audit_id): string {
504 $difficulty_map = [
505 'unminified-css' => 'Easy',
506 'unminified-javascript' => 'Easy',
507 'modern-image-formats' => 'Easy',
508 'offscreen-images' => 'Medium',
509 'unused-css-rules' => 'Hard',
510 'unused-javascript' => 'Hard',
511 'render-blocking-resources' => 'Medium',
512 'efficient-animated-content' => 'Medium',
513 'duplicated-javascript' => 'Hard',
514 'legacy-javascript' => 'Medium'
515 ];
516
517 return $difficulty_map[$audit_id] ?? 'Medium';
518 }
519
520 /**
521 * Get rate limit configuration
522 * Following ThinkRank rate limiting patterns
523 *
524 * @return array Rate limit configuration
525 */
526 protected function get_rate_limits(): array {
527 return [
528 'max_requests_per_day' => self::MAX_REQUESTS_PER_DAY,
529 'reset_time' => get_transient(self::RATE_LIMIT_KEY . '_reset') ?: strtotime('tomorrow')
530 ];
531 }
532
533 /**
534 * Get rate limit transient key
535 * Following ThinkRank option naming patterns
536 *
537 * @return string Rate limit key
538 */
539 protected function get_rate_limit_key(): string {
540 return self::RATE_LIMIT_KEY;
541 }
542
543 /**
544 * Get rate limit error message
545 *
546 * @return string Error message
547 */
548 protected function get_rate_limit_error_message(): string {
549 return 'PageSpeed Insights API rate limit exceeded. Try again tomorrow.';
550 }
551 }
552