PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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
← All changes | includes/integrations/class-google-pagespeed-client.php +208 -17 1.0.22.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'],