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 +93 -13 1.29.02.7.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
@@ -265,16 +330,32 @@
265 330 'good_threshold' => 2.5,
266 331 'needs_improvement_threshold' => 4.0,
267 332 'description' => 'Time until the largest content element is rendered'
268 333 ],
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,
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,
273 353 'unit' => 'ms',
274 - 'good_threshold' => 100,
275 - 'needs_improvement_threshold' => 300,
276 - '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'])
277 358 ],
278 359 'cls' => [
279 360 'name' => 'Cumulative Layout Shift',
280 361 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4),
@@ -362,10 +443,9 @@
362 443 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'],
363 444 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'],
364 445 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'],
365 446 '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'],
447 + 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'INP'],
368 448 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'],
369 449 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'],
370 450 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'],
371 451 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'],