PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 53 releases
← All changes | includes/integrations/class-google-pagespeed-client.php +99 -13 1.29.0 → 2.12.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
@@ -203,10 +268,16 @@
203 268 'test_url' => $test_url,
204 269 'performance_score' => $performance_score
205 270 ];
206 271 } catch (\Exception $e) {
272 + // `message` as well as `error`: the success branch above reports
273 + // its outcome as `message`, and every consumer reads that key, so
274 + // a failure that only set `error` was reported to the admin as an
275 + // error with no reason at all (#852). `error` is kept for any
276 + // caller that already reads it.
207 277 return [
208 278 'success' => false,
279 + 'message' => $e->getMessage(),
209 280 'error' => $e->getMessage()
210 281 ];
211 282 }
212 283 }
@@ -265,16 +336,32 @@
265 336 'good_threshold' => 2.5,
266 337 'needs_improvement_threshold' => 4.0,
267 338 'description' => 'Time until the largest content element is rendered'
268 339 ],
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,
340 + // INP replaced FID as a Core Web Vital in March 2024. INP is a field
341 + // metric — a standard PSI navigation run has no interaction to
342 + // measure — so Lighthouse only reports it in timespan mode. Read that
343 + // audit when it is present and otherwise fall back to Total Blocking
344 + // Time, which is Google's documented lab proxy for INP. Real INP
345 + // comes from the CrUX field data in Performance_Monitoring_Manager.
346 + 'inp' => [
347 + 'name' => 'Interaction to Next Paint',
348 + 'value' => round(
349 + $audits['interaction-to-next-paint']['numericValue']
350 + ?? $audits['total-blocking-time']['numericValue']
351 + ?? 0,
352 + 4
353 + ),
354 + 'score' => (
355 + $audits['interaction-to-next-paint']['score']
356 + ?? $audits['total-blocking-time']['score']
357 + ?? 0
358 + ) * 100,
273 359 'unit' => 'ms',
274 - 'good_threshold' => 100,
275 - 'needs_improvement_threshold' => 300,
276 - 'description' => 'Time from first user interaction to browser response'
360 + 'good_threshold' => 200,
361 + 'needs_improvement_threshold' => 500,
362 + 'description' => 'Responsiveness across all interactions on the page',
363 + 'is_lab_proxy' => !isset($audits['interaction-to-next-paint'])
277 364 ],
278 365 'cls' => [
279 366 'name' => 'Cumulative Layout Shift',
280 367 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4),
@@ -362,10 +449,9 @@
362 449 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'],
363 450 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'],
364 451 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'],
365 452 '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'],
453 + 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'INP'],
368 454 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'],
369 455 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'],
370 456 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'],
371 457 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'],