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.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 1.0.0 1.0.1 1.0.2 1.1.0 All 49 releases
← All changes | includes/integrations/class-google-pagespeed-client.php +208 -16 1.10.02.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,9 +150,11 @@
58 150 $endpoint = '/runPagespeed';
59 151 $params = [
60 152 'url' => $url,
61 153 'strategy' => $strategy,
62 - 'category' => $categories,
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,
63 157 ];
64 158
65 159 $full_url = self::API_BASE_URL . $endpoint;
66 160 return $this->make_request($full_url, $params, 'GET');
@@ -66,8 +160,94 @@
66 160 return $this->make_request($full_url, $params, 'GET');
67 161 }
68 162
69 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 + /**
70 250 * Test API connection
71 251 * Following ThinkRank test_connection patterns from AI clients
72 252 *
73 253 * @return array Connection test results
@@ -104,10 +284,9 @@
104 284 * @return array Core Web Vitals data
105 285 * @throws \Exception If API request fails
106 286 */
107 287 public function get_core_web_vitals(string $url, string $strategy = 'mobile'): array {
108 - $result = $this->run_pagespeed_test($url, $strategy, ['performance']);
109 - return $this->parse_core_web_vitals($result);
288 + return $this->get_pagespeed_snapshot($url, $strategy)['core_web_vitals'];
110 289 }
111 290
112 291 /**
113 292 * Get performance opportunities for a URL
@@ -117,10 +296,9 @@
117 296 * @return array Performance opportunities
118 297 * @throws \Exception If API request fails
119 298 */
120 299 public function get_opportunities(string $url, string $strategy = 'mobile'): array {
121 - $result = $this->run_pagespeed_test($url, $strategy, ['performance']);
122 - return $this->parse_opportunities($result);
300 + return $this->get_pagespeed_snapshot($url, $strategy)['opportunities'];
123 301 }
124 302
125 303 /**
126 304 * Get diagnostic information for a URL
@@ -130,10 +308,9 @@
130 308 * @return array Diagnostic information
131 309 * @throws \Exception If API request fails
132 310 */
133 311 public function get_diagnostics(string $url, string $strategy = 'mobile'): array {
134 - $result = $this->run_pagespeed_test($url, $strategy, ['performance']);
135 - return $this->parse_diagnostics($result);
312 + return $this->get_pagespeed_snapshot($url, $strategy)['diagnostics'];
136 313 }
137 314
138 315 /**
139 316 * Parse Core Web Vitals from PageSpeed response
@@ -153,16 +330,32 @@
153 330 'good_threshold' => 2.5,
154 331 'needs_improvement_threshold' => 4.0,
155 332 'description' => 'Time until the largest content element is rendered'
156 333 ],
157 - 'fid' => [
158 - 'name' => 'First Input Delay',
159 - 'value' => round(($audits['max-potential-fid']['numericValue'] ?? 0), 4),
160 - '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,
161 353 'unit' => 'ms',
162 - 'good_threshold' => 100,
163 - 'needs_improvement_threshold' => 300,
164 - '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'])
165 358 ],
166 359 'cls' => [
167 360 'name' => 'Cumulative Layout Shift',
168 361 'value' => round(($audits['cumulative-layout-shift']['numericValue'] ?? 0), 4),
@@ -250,10 +443,9 @@
250 443 'first-contentful-paint' => ['title' => 'First Contentful Paint', 'impact' => 'Performance'],
251 444 'largest-contentful-paint' => ['title' => 'Largest Contentful Paint', 'impact' => 'LCP'],
252 445 'first-meaningful-paint' => ['title' => 'First Meaningful Paint', 'impact' => 'Performance'],
253 446 'speed-index' => ['title' => 'Speed Index', 'impact' => 'Performance'],
254 - 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'Performance'],
255 - 'max-potential-fid' => ['title' => 'Max Potential First Input Delay', 'impact' => 'FID'],
447 + 'total-blocking-time' => ['title' => 'Total Blocking Time', 'impact' => 'INP'],
256 448 'cumulative-layout-shift' => ['title' => 'Cumulative Layout Shift', 'impact' => 'CLS'],
257 449 'server-response-time' => ['title' => 'Initial server response time was short', 'impact' => 'Performance'],
258 450 'interactive' => ['title' => 'Time to Interactive', 'impact' => 'Performance'],
259 451 'user-timings' => ['title' => 'User Timing marks and measures', 'impact' => 'Performance'],