PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-preloader.php +616 -65 1.0.3 → 1.4.1 View file →
@@ -35,12 +35,299 @@
35 35
36 36 public const STATE_KEY = 'xspeed_preloader_state';
37 37 public const STATE_TTL = 86400; // 24h — long enough for slow crawls.
38 38 public const CRON_HOOK = 'xspeed_preloader_tick';
39 - public const USER_AGENT = 'xSpeed-Preloader/1.0 (+cache warmer; admin-initiated)';
39 + /**
40 + * User-agent for every request the preloader makes.
41 + *
42 + * Deliberately contains no substring from the 7G/8G bad-bot lists. The
43 + * previous value, "xSpeed-Preloader/1.0", matched the `loader` token in
44 + * the alphabetical slice `(linkscan|linkwalker|loader|lwp-download|...)`
45 + * — a match on "Pre*loader*" — so nginx ports of 8G answered every warm
46 + * with 403 and newly published posts were never warmed. Upstream 8G
47 + * v1.5 has since dropped `loader`, but forks and vendored copies (xCloud
48 + * among them) still ship the older slice, so the name has to stay clear
49 + * of it. "Warmer" matches nothing in either list. (#481)
50 + *
51 + * Read through user_agent() rather than using this constant directly, so
52 + * the `xspeed_preloader_user_agent` filter applies.
53 + */
54 + public const USER_AGENT = 'xSpeed-Warmer/1.0 (+cache warmer; admin-initiated)';
40 55 public const REQUEST_TIMEOUT = 8;
41 56
42 57 /**
58 + * Default cap on NEW remote images resolved per warmed page.
59 + *
60 + * A crawl warms the cache; it is not a licence to hit third-party hosts
61 + * hundreds of times for one page.
62 + *
63 + * The cap counts only images whose dimensions are not already known, and
64 + * results persist between runs — so each crawl advances through a heavily
65 + * embedded page rather than re-picking the same first N. That is what
66 + * makes a cap safe here: without the skip it would strand everything past
67 + * the limit permanently, because images appear in the same DOM order
68 + * every time.
69 + *
70 + * 20 is a starting point, not a measurement. Sites that embed more can
71 + * raise it via `xspeed_preloader_remote_dimension_limit`.
72 + */
73 + private const REMOTE_DIMENSION_LIMIT = 20;
74 +
75 + /**
76 + * The user-agent every preloader request sends.
77 + *
78 + * Filterable because the blocking rule lives on the server, not here: a
79 + * host with its own bad-bot list can clear a warm without patching the
80 + * plugin or waiting for a release. An empty filter return is ignored —
81 + * sending no UA gets a request blocked at least as often. (#481)
82 + */
83 + public static function user_agent(): string {
84 + /**
85 + * Filter the preloader's user-agent string.
86 + *
87 + * @param string $user_agent Default self::USER_AGENT.
88 + */
89 + $ua = apply_filters( 'xspeed_preloader_user_agent', self::USER_AGENT );
90 +
91 + return ( is_string( $ua ) && '' !== trim( $ua ) ) ? trim( $ua ) : self::USER_AGENT;
92 + }
93 +
94 + /**
95 + * A real phone browser's user-agent, which the warmer's own is appended
96 + * to for the phone copy.
97 + *
98 + * A real phone string rather than the warmer's UA plus a client hint:
99 + * a theme or plugin that reads the user-agent itself (Mobile_Detect and
100 + * the like) instead of calling wp_is_mobile() would otherwise render its
101 + * desktop HTML into the phone copy. The warmer's name stays at the end so
102 + * the request is still recognisable in access logs and still matches no
103 + * bad-bot rule. (#596)
104 + */
105 + public const MOBILE_UA_PREFIX = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
106 +
107 + /** The user-agent the phone warm sends. */
108 + public static function mobile_user_agent(): string {
109 + $default = self::MOBILE_UA_PREFIX . ' ' . self::user_agent();
110 + /**
111 + * Filter the user-agent the preloader sends when it warms the phone
112 + * copy of a page (Separate Mobile Cache on).
113 + *
114 + * It must still read as a phone: the cache files the response by the
115 + * same test as wp_is_mobile(). An empty return is ignored.
116 + *
117 + * @param string $user_agent Default: an iPhone Safari string followed by the warmer's user-agent.
118 + */
119 + $ua = apply_filters( 'xspeed_preloader_mobile_user_agent', $default );
120 +
121 + return ( is_string( $ua ) && '' !== trim( $ua ) ) ? trim( $ua ) : $default;
122 + }
123 +
124 + /**
125 + * The copies of a page a warm fills: the desktop one, and the phone one
126 + * too when Separate Mobile Cache keeps one per device. Before #596 every
127 + * warm sent a desktop user-agent, so the first phone visitor to each
128 + * page got an uncached render.
129 + *
130 + * @return string[] 'desktop', then 'mobile' when it applies.
131 + */
132 + public static function devices(): array {
133 + $cache = Settings_Manager::get( 'cache' );
134 + $devices = empty( $cache['mobile_separate'] ) ? array( 'desktop' ) : array( 'desktop', 'mobile' );
135 + /**
136 + * Filter which copies of a page the preloader warms.
137 + *
138 + * Return array( 'desktop' ) to skip the phone copy, for example on a
139 + * host where the extra requests cost too much. Unknown values are
140 + * dropped; an empty list means desktop.
141 + *
142 + * @param string[] $devices 'desktop', plus 'mobile' when Separate Mobile Cache is on.
143 + */
144 + $filtered = apply_filters( 'xspeed_preloader_devices', $devices );
145 + $filtered = is_array( $filtered ) ? array_values( array_intersect( array( 'desktop', 'mobile' ), $filtered ) ) : $devices;
146 +
147 + return empty( $filtered ) ? array( 'desktop' ) : $filtered;
148 + }
149 +
150 + /**
151 + * Request arguments for one warm of one device copy.
152 + *
153 + * `Sec-CH-UA-Mobile` settles the device whatever the user-agent filters
154 + * return: the cache checks that hint before the user-agent.
155 + *
156 + * @param array<string,string> $headers Extra request headers.
157 + */
158 + private static function warm_args( string $device, array $headers = array() ): array {
159 + $headers['Sec-CH-UA-Mobile'] = 'mobile' === $device ? '?1' : '?0';
160 + return array(
161 + 'timeout' => self::REQUEST_TIMEOUT,
162 + 'sslverify' => false,
163 + 'user-agent' => 'mobile' === $device ? self::mobile_user_agent() : self::user_agent(),
164 + 'headers' => Self_Traffic::headers( $headers ),
165 + 'blocking' => true,
166 + );
167 + }
168 +
169 + /**
170 + * Warm every copy of $url that devices() names.
171 + *
172 + * @param array<string,string> $headers Extra request headers.
173 + * @return array{failures: array<int,array{device:string,error:string}>, body: string}
174 + * The failures, and the desktop response body ('' when it failed).
175 + */
176 + private static function warm_devices( string $url, array $headers = array() ): array {
177 + $failures = array();
178 + $body = '';
179 + foreach ( self::devices() as $device ) {
180 + $args = self::warm_args( $device, $headers );
181 + $response = wp_remote_get( $url, $args );
182 + if ( is_wp_error( $response ) ) {
183 + $failures[] = array( 'device' => $device, 'error' => $response->get_error_message() );
184 + continue;
185 + }
186 + $code = (int) wp_remote_retrieve_response_code( $response );
187 + if ( $code >= 400 ) {
188 + $failures[] = array( 'device' => $device, 'error' => self::failure_detail( $code, $args['user-agent'] ) );
189 + if ( self::is_firewall_block( $code ) ) {
190 + self::remember_firewall_block( $url, $code, $args['user-agent'] );
191 + }
192 + continue;
193 + }
194 + if ( 'desktop' === $device ) {
195 + $body = (string) wp_remote_retrieve_body( $response );
196 + }
197 + }
198 + // The notice goes only once every copy got through. A host that
199 + // blocks just the phone user-agent would otherwise have the notice
200 + // cleared by each desktop warm right after the phone warm set it.
201 + if ( empty( $failures ) ) {
202 + self::clear_firewall_block();
203 + }
204 + return array( 'failures' => $failures, 'body' => $body );
205 + }
206 +
207 + /** "phone: " for a failure of the phone copy, so the log says which copy failed. */
208 + private static function device_prefix( string $device ): string {
209 + return 'mobile' === $device ? 'phone: ' : '';
210 + }
211 +
212 + /**
213 + * Is this status code the signature of a firewall refusing our warmer?
214 + *
215 + * 403 and 406 are what bad-bot rules (7G/8G, mod_security, Wordfence)
216 + * answer with. We only ever warm our OWN origin, and a page a visitor can
217 + * load must be loadable by us too — so these codes mean the request was
218 + * judged by its user-agent, not that the page is missing or broken. (#481)
219 + */
220 + private static function is_firewall_block( int $code ): bool {
221 + return in_array( $code, array( 403, 406 ), true );
222 + }
223 +
224 + /**
225 + * Explain a warm failure in terms the admin can act on.
226 + *
227 + * A bare "HTTP 403" sent people hunting a broken page; the page is fine,
228 + * and the fix is a server rule, so the message has to name the cause and
229 + * the exact UA to allow. (#481)
230 + */
231 + private static function failure_detail( int $code, string $user_agent = '' ): string {
232 + if ( ! self::is_firewall_block( $code ) ) {
233 + return sprintf( 'HTTP %d', $code );
234 + }
235 +
236 + return sprintf(
237 + 'HTTP %d — your server\'s firewall is blocking the xSpeed cache warmer by user-agent, so this page was not warmed. Allow the user-agent "%s" (on xCloud this is the 8G firewall\'s bad-bot rule), or change it with the xspeed_preloader_user_agent filter.',
238 + $code,
239 + '' !== $user_agent ? $user_agent : self::user_agent()
240 + );
241 + }
242 +
243 + /** Option holding the last firewall-shaped warm refusal. */
244 + public const FIREWALL_BLOCK_OPTION = 'xspeed_preloader_firewall_block';
245 +
246 + /**
247 + * Record that the origin refused a warm by user-agent, for ui_notices().
248 + *
249 + * An option rather than a transient: the condition is a server rule that
250 + * persists until someone changes it, and a notice that expired on its own
251 + * would let a site go back to never warming, silently. Cleared by
252 + * clear_firewall_block() on the first warm that succeeds. (#481)
253 + */
254 + private static function remember_firewall_block( string $url, int $code, string $user_agent = '' ): void {
255 + if ( ! function_exists( 'update_option' ) ) {
256 + return;
257 + }
258 + update_option(
259 + self::FIREWALL_BLOCK_OPTION,
260 + array(
261 + 'url' => $url,
262 + 'code' => $code,
263 + 'user_agent' => '' !== $user_agent ? $user_agent : self::user_agent(),
264 + 'ts' => time(),
265 + ),
266 + false
267 + );
268 + }
269 +
270 + /** Forget the firewall block once a warm gets through. */
271 + public static function clear_firewall_block(): void {
272 + if ( function_exists( 'delete_option' ) && self::firewall_block() ) {
273 + delete_option( self::FIREWALL_BLOCK_OPTION );
274 + }
275 + }
276 +
277 + /** The last firewall-shaped refusal, or null when there isn't one. */
278 + public static function firewall_block(): ?array {
279 + if ( ! function_exists( 'get_option' ) ) {
280 + return null;
281 + }
282 + $block = get_option( self::FIREWALL_BLOCK_OPTION, null );
283 +
284 + return ( is_array( $block ) && ! empty( $block['code'] ) ) ? $block : null;
285 + }
286 +
287 + /**
288 + * How many new remote images one warmed page may resolve.
289 + */
290 + private static function remote_dimension_limit(): int {
291 + /**
292 + * Filter the per-page cap on remote dimension lookups.
293 + *
294 + * @param int $limit Default 20. Values below 1 disable the lookup.
295 + */
296 + return (int) apply_filters( 'xspeed_preloader_remote_dimension_limit', self::REMOTE_DIMENSION_LIMIT );
297 + }
298 +
299 + /**
300 + * Why the top-level sitemap fetch failed on this request, or '' when it
301 + * succeeded. Set by fetch_sitemap_urls(), read by resolve_queue() — the
302 + * reason has to survive the return of an empty array, which is exactly
303 + * what it could not do before. Request-scoped; never persisted. (#142)
304 + *
305 + * @var string
306 + */
307 + private static $last_sitemap_error = '';
308 +
309 + /**
310 + * The sitemap URL the last error refers to. Kept beside the message so
311 + * an error entry can carry a real `url` field like every other one,
312 + * rather than repeating the URL already inside the message text.
313 + */
314 + private static $last_sitemap_url = '';
315 +
316 + /**
317 + * How the queue for the current crawl was built — 'sitemap', 'fallback'
318 + * (enumerated from the database because the sitemap was unreachable), or
319 + * 'none'. Surfaced in the state so the panel, REST and CLI can each say
320 + * what actually happened instead of reporting a bare zero. (#142)
321 + *
322 + * @var string
323 + */
324 + private static $queue_source = 'none';
325 +
326 + /** Largest number of URLs the database fallback will enumerate. */
327 + private const FALLBACK_LIMIT = 500;
328 +
329 + /**
43 330 * Kick off a fresh crawl. Returns the initial state.
44 331 */
45 332 public static function start(): array {
46 333 $opts = Settings_Manager::get( 'preloader' );
@@ -45,25 +332,77 @@
45 332 public static function start(): array {
46 333 $opts = Settings_Manager::get( 'preloader' );
47 334 $urls = self::resolve_queue( $opts );
48 335
336 + // A crawl that queued nothing because the sitemap was unreachable is a
337 + // FAILURE, and every layer above needs to be able to say so. It used
338 + // to be indistinguishable from success: errors stayed empty, the REST
339 + // route returned 200, and the CLI printed a green Success. (#142)
340 + $sitemap_error = self::$last_sitemap_error;
341 + $errors = array();
342 + if ( '' !== $sitemap_error && empty( $urls ) ) {
343 + // Same {url, error, ts} shape every other entry uses. A bare
344 + // string here fataled `wp xspeed preloader status`, which
345 + // destructures `$e['url']` over the list — and took the MCP
346 + // `get_preloader_status` tool down with it, so an agent asking
347 + // why the preload failed got "Cannot access offset of type
348 + // string on string" instead of the reason this code records.
349 + // `url` is the sitemap because that is what failed. (QA F1)
350 + $errors[] = array(
351 + 'url' => self::$last_sitemap_url,
352 + 'error' => $sitemap_error,
353 + 'ts' => time(),
354 + );
355 + }
356 +
49 357 $state = array(
50 - 'running' => ! empty( $urls ),
51 - 'started_at' => time(),
52 - 'finished_at' => 0,
53 - 'queue' => array_values( $urls ),
54 - 'processed' => 0,
55 - 'total' => count( $urls ),
56 - 'last_url' => '',
57 - 'errors' => array(),
358 + 'running' => ! empty( $urls ),
359 + 'started_at' => time(),
360 + 'finished_at' => empty( $urls ) ? time() : 0,
361 + 'queue' => array_values( $urls ),
362 + 'processed' => 0,
363 + 'total' => count( $urls ),
364 + 'last_url' => '',
365 + 'errors' => $errors,
366 + // Consumers render on these: the panel needs to distinguish
367 + // "not started" from "ran and found nothing", and to tell the
368 + // user when the queue came from the fallback rather than the
369 + // sitemap they configured.
370 + 'source' => self::$queue_source,
371 + 'sitemap_error' => $sitemap_error,
372 + // Which copies each URL gets, for the panel and the status
373 + // command. Each tick refreshes it: turning Separate Mobile
374 + // Cache on or off mid-crawl purges the cache, and the rest of
375 + // the crawl should fill the copies that now exist.
376 + 'devices' => self::devices(),
58 377 );
59 378 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
60 379
61 - Activity_Log::record(
62 - 'preloader_started',
63 - sprintf( 'Preloader queued %d URL%s for warming.', $state['total'], 1 === $state['total'] ? '' : 's' ),
64 - $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN
65 - );
380 + if ( '' !== $sitemap_error && 'fallback' === self::$queue_source ) {
381 + $message = sprintf(
382 + /* translators: 1: number of URLs, 2: the sitemap failure detail. */
383 + __( 'Preloader queued %1$d URLs from the site content — %2$s', 'xspeed' ),
384 + $state['total'],
385 + $sitemap_error
386 + );
387 + $severity = Activity_Log::WARN;
388 + } elseif ( '' !== $sitemap_error ) {
389 + $message = sprintf(
390 + /* translators: %s: the sitemap failure detail. */
391 + __( 'Preloader could not start — %s', 'xspeed' ),
392 + $sitemap_error
393 + );
394 + $severity = Activity_Log::WARN;
395 + } else {
396 + $message = sprintf(
397 + /* translators: 1: number of URLs, 2: plural suffix. */
398 + __( 'Preloader queued %1$d URL%2$s for warming.', 'xspeed' ),
399 + $state['total'],
400 + 1 === $state['total'] ? '' : 's'
401 + );
402 + $severity = $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN;
403 + }
404 + Activity_Log::record( 'preloader_started', $message, $severity );
66 405
67 406 // Schedule the first tick ~5 seconds out so the kick-off REST call
68 407 // returns instantly; wp_schedule_single_event covers the
69 408 // "process the queue ASAP" path without a heavy synchronous loop.
@@ -134,15 +473,41 @@
134 473
135 474 $opts = Settings_Manager::get( 'preloader' );
136 475 $batch = max( 1, min( 50, (int) ( $opts['batch_size'] ?? 5 ) ) );
137 476
138 - $processed_this_tick = 0;
139 - while ( $processed_this_tick < $batch && ! empty( $state['queue'] ) ) {
140 - $url = array_shift( $state['queue'] );
477 + /**
478 + * Filter: xspeed_preload_batch_size
479 + *
480 + * How much one tick may warm, in the units the batch size setting
481 + * counts. The setting is the site owner's
482 + * intent; this is for anything that knows a lower ceiling applies —
483 + * a CDN with a rate limit in front, say, which will answer a burst
484 + * with a challenge and leave the queue looking warmed when it is not.
485 + *
486 + * Only ever lowers. A filter that raised it would let an add-on
487 + * overrule a number the site owner chose, and the reason to reach for
488 + * this is always that something cannot take the current rate.
489 + *
490 + * @param int $batch The batch this tick would otherwise use.
491 + */
492 + $ceiling = (int) apply_filters( 'xspeed_preload_batch_size', $batch );
493 + if ( $ceiling > 0 && $ceiling < $batch ) {
494 + $batch = $ceiling;
495 + }
496 +
497 + // batch_size caps requests, not URLs, so the load on the origin per
498 + // tick is the same with the phone copy on: each URL costs one request
499 + // per device. A tick always warms at least one URL.
500 + $devices = self::devices();
501 + $state['devices'] = $devices;
502 + $per_url = count( $devices );
503 + $requests = 0;
504 + while ( ! empty( $state['queue'] ) && ( 0 === $requests || $requests + $per_url <= $batch ) ) {
505 + $url = (string) array_shift( $state['queue'] );
141 506 self::warm_url( $url, $state );
142 507 $state['processed']++;
143 508 $state['last_url'] = $url;
144 - $processed_this_tick++;
509 + $requests += $per_url;
145 510 }
146 511
147 512 if ( empty( $state['queue'] ) ) {
148 513 self::mark_complete( $state );
@@ -168,32 +533,17 @@
168 533 public static function warm_one( string $url, string $cause = 'manual' ): bool {
169 534 if ( '' === $url ) {
170 535 return false;
171 536 }
172 - $response = wp_remote_get(
173 - $url,
174 - array(
175 - 'timeout' => self::REQUEST_TIMEOUT,
176 - 'sslverify' => false,
177 - 'user-agent' => self::USER_AGENT,
178 - 'blocking' => true,
179 - )
180 - );
181 - if ( is_wp_error( $response ) ) {
537 + $result = self::warm_devices( $url );
538 + foreach ( $result['failures'] as $failure ) {
182 539 Activity_Log::record(
183 540 'preloader_warm_failed',
184 - sprintf( 'Warm %s failed (%s): %s', $cause, $url, $response->get_error_message() ),
541 + sprintf( 'Warm %s failed (%s): %s%s', $cause, $url, self::device_prefix( $failure['device'] ), $failure['error'] ),
185 542 Activity_Log::WARN
186 543 );
187 - return false;
188 544 }
189 - $code = (int) wp_remote_retrieve_response_code( $response );
190 - if ( $code >= 400 ) {
191 - Activity_Log::record(
192 - 'preloader_warm_failed',
193 - sprintf( 'Warm %s failed (%s): HTTP %d', $cause, $url, $code ),
194 - Activity_Log::WARN
195 - );
545 + if ( ! empty( $result['failures'] ) ) {
196 546 return false;
197 547 }
198 548 Activity_Log::record(
199 549 'preloader_warmed_one',
@@ -203,40 +553,110 @@
203 553 return true;
204 554 }
205 555
206 556 private static function warm_url( string $url, array &$state ): void {
207 - $response = wp_remote_get(
208 - $url,
209 - array(
210 - 'timeout' => self::REQUEST_TIMEOUT,
211 - 'sslverify' => false,
212 - 'user-agent' => self::USER_AGENT,
213 - 'headers' => array(
214 - 'Accept' => 'text/html,application/xhtml+xml',
215 - ),
216 - 'blocking' => true,
217 - )
218 - );
219 - if ( is_wp_error( $response ) ) {
557 + $result = self::warm_devices( $url, array( 'Accept' => 'text/html,application/xhtml+xml' ) );
558 + foreach ( $result['failures'] as $failure ) {
220 559 $state['errors'][] = array(
221 560 'url' => $url,
222 - 'error' => $response->get_error_message(),
561 + 'error' => self::device_prefix( $failure['device'] ) . $failure['error'],
223 562 'ts' => time(),
224 563 );
225 - // Cap retained errors so a broken sitemap doesn't blow the
226 - // transient size.
227 - $state['errors'] = array_slice( $state['errors'], -20 );
564 + }
565 + // Cap retained errors so a broken sitemap doesn't blow the
566 + // transient size.
567 + $state['errors'] = array_slice( $state['errors'], -20 );
568 + if ( '' !== $result['body'] ) {
569 + self::warm_remote_dimensions( $result['body'] );
570 + }
571 + }
572 +
573 + /**
574 + * Resolve dimensions for externally hosted images found on a warmed page.
575 + *
576 + * The crawl already has the HTML in hand, so harvesting image URLs from it
577 + * costs nothing extra — and this is the one place where paying for a
578 + * remote lookup is free of consequence, because no visitor is waiting.
579 + *
580 + * An image on another domain has no local file to measure, so the front
581 + * end skips it and the page ships without width/height — which is layout
582 + * shift, on precisely the sites least able to fix it by hand (a CDN, a
583 + * sister site, a shared asset host). Warming here means the NEXT render
584 + * finds the dimensions in cache and stamps them, with the visitor paying
585 + * nothing.
586 + *
587 + * Deliberately bounded per page: a crawl should not turn into a scraper
588 + * for a page embedding hundreds of third-party images.
589 + *
590 + * @param string $html The warmed page's HTML.
591 + */
592 + private static function warm_remote_dimensions( string $html ): void {
593 + if ( '' === $html || ! class_exists( '\XSpeed\Lazy_Loader' ) ) {
228 594 return;
229 595 }
230 - $code = (int) wp_remote_retrieve_response_code( $response );
231 - if ( $code >= 400 ) {
232 - $state['errors'][] = array(
233 - 'url' => $url,
234 - 'error' => sprintf( 'HTTP %d', $code ),
235 - 'ts' => time(),
236 - );
237 - $state['errors'] = array_slice( $state['errors'], -20 );
596 +
597 + $opts = Settings_Manager::get( 'lazy' );
598 + if ( empty( $opts['add_missing_dimensions'] ) ) {
599 + return;
238 600 }
601 +
602 + // Match any <img>, not only one carrying `src`. The URL worth warming
603 + // may live in a lazy attribute instead — which is the whole point of
604 + // #328 — and resolvable_image_url() below is what knows where to look.
605 + if ( ! preg_match_all( '#<img\b[^>]*>#i', $html, $m, PREG_SET_ORDER ) ) {
606 + return;
607 + }
608 +
609 + $home = wp_parse_url( home_url(), PHP_URL_HOST );
610 + $targets = array();
611 + foreach ( $m as $tag ) {
612 + // Only tags MISSING a dimension are worth resolving — one that
613 + // already declares both needs nothing.
614 + // Same lookbehind as Lazy_Loader::ensure_dimensions(): a bare
615 + // `\bwidth=` also matches `data-width=`, so a slider carrying its
616 + // own metadata looked already-sized and was skipped from warming.
617 + // The two must agree, or the collector skips exactly the tags the
618 + // renderer still needs measured. (#333 review round 3, issue 2)
619 + if ( preg_match( '#(?<![-\w])width\s*=#i', $tag[0] ) && preg_match( '#(?<![-\w])height\s*=#i', $tag[0] ) ) {
620 + continue;
621 + }
622 + // Ask the same resolver the render path uses, rather than reading
623 + // `src` directly. A slider parks a spacer in `src` and the real
624 + // URL in `data-lazy`/`data-src`/`data-original`, so a collector
625 + // looking only at `src` warmed the SPACER and never the image —
626 + // leaving remotely-hosted slider images unresolvable at render
627 + // time, the exact markup #328 is about. (#333 review round 2,
628 + // issue 3)
629 + // `false`: do not let the resolver settle a name-refused URL by
630 + // MEASURING it. That is circular here — remote measurement is
631 + // gated until warm_dimensions() sets $warming, and this collector
632 + // is what feeds warm_dimensions(). Take the URL the tag offers and
633 + // let the warm pass decide. (#333 review round 3, issue 3)
634 + $src = Lazy_Loader::resolvable_image_url( $tag[0], false );
635 + if ( '' === $src || ! preg_match( '#^https?://#i', $src ) ) {
636 + continue;
637 + }
638 + $host = wp_parse_url( $src, PHP_URL_HOST );
639 + if ( ! $host || $host === $home ) {
640 + continue; // Local images already resolve from disk.
641 + }
642 + // Already resolved (or already known unresolvable) — looking it up
643 + // again costs a request and teaches us nothing. Skipping it is
644 + // also what makes the cap below advance: images appear in the same
645 + // DOM order every crawl, so a collector that did not skip would
646 + // re-pick the same first N for ever and never reach the rest.
647 + if ( Lazy_Loader::dimensions_known( $src ) ) {
648 + continue;
649 + }
650 + $targets[ $src ] = true;
651 + if ( count( $targets ) >= self::remote_dimension_limit() ) {
652 + break;
653 + }
654 + }
655 +
656 + if ( $targets ) {
657 + Lazy_Loader::warm_dimensions( array_keys( $targets ) );
658 + }
239 659 }
240 660
241 661 private static function mark_complete( array $state ): void {
242 662 $state['running'] = false;
@@ -263,8 +683,14 @@
263 683 *
264 684 * @return string[]
265 685 */
266 686 private static function resolve_queue( array $opts ): array {
687 + // Request-scoped statics: reset so a previous crawl in the same
688 + // process can't leak its verdict into this one.
689 + self::$last_sitemap_error = '';
690 + self::$last_sitemap_url = '';
691 + self::$queue_source = 'none';
692 +
267 693 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
268 694 if ( '' === $sitemap ) {
269 695 $sitemap = home_url( '/wp-sitemap.xml' );
270 696 }
@@ -269,9 +695,28 @@
269 695 $sitemap = home_url( '/wp-sitemap.xml' );
270 696 }
271 697
272 698 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
699 + $from = empty( $urls ) ? 'none' : 'sitemap';
273 700
701 + /*
702 + * A missing sitemap must not disable the feature. Two very common
703 + * setups produce one with no misconfiguration by the user:
704 + * `blog_public = 0` (WordPress disables /wp-sitemap.xml outright,
705 + * standard on staging and pre-launch sites), and an SEO plugin
706 + * filtering `wp_sitemaps_enabled` to false while serving its own
707 + * sitemap at a path we were never told about.
708 + *
709 + * Enumerate warmable URLs straight from the database instead. Only
710 + * on a genuine fetch FAILURE — a sitemap that is reachable and
711 + * legitimately empty is a real answer, and silently crawling
712 + * something else would be worse than doing nothing. (#142)
713 + */
714 + if ( empty( $urls ) && '' !== self::$last_sitemap_error ) {
715 + $urls = self::fallback_urls();
716 + $from = empty( $urls ) ? 'none' : 'fallback';
717 + }
718 +
274 719 $cache_opts = Settings_Manager::get( 'cache' );
275 720 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
276 721 if ( ! empty( $excluded ) ) {
277 722 $urls = array_filter(
@@ -289,12 +734,90 @@
289 734 }
290 735
291 736 // Dedup + cap at 5000 to bound the transient size on huge sites.
292 737 $urls = array_values( array_unique( $urls ) );
293 - return array_slice( $urls, 0, 5000 );
738 + $urls = array_slice( $urls, 0, 5000 );
739 +
740 + /*
741 + * Commit the verdict only now, AFTER the exclusion filter — the
742 + * source describes what we ACTUALLY queued, not what we hoped to.
743 + * Setting it earlier let a queue that the exclusions stripped to
744 + * nothing still claim `fallback`, so the panel announced "Warmed
745 + * from site content" over 0 URLs, and `crawlFailed` (which needs
746 + * source !== 'fallback' at total 0) could never become true.
747 + * One assignment fixes both. (QA F2/F3 on #155)
748 + */
749 + self::$queue_source = empty( $urls ) ? 'none' : $from;
750 +
751 + return $urls;
294 752 }
295 753
296 754 /**
755 + * Enumerate warmable URLs from the database, for sites whose sitemap
756 + * can't be fetched. Deliberately modest in scope: the home page, then
757 + * the most recently modified public posts across every public post type.
758 + * Newest-first is the right bias — those are the URLs most likely to be
759 + * requested and least likely to be warm already.
760 + *
761 + * Uses WP_Query rather than SQL so post-type registration, status
762 + * handling and multisite switching all behave the way the rest of
763 + * WordPress does.
764 + *
765 + * @return string[]
766 + */
767 + private static function fallback_urls(): array {
768 + $urls = array();
769 + $home = (string) home_url( '/' );
770 + if ( '' !== trim( $home, '/' ) ) {
771 + $urls[] = $home;
772 + }
773 +
774 + $types = get_post_types(
775 + array(
776 + 'public' => true,
777 + 'publicly_queryable' => true,
778 + )
779 + );
780 + // `page` is public but not publicly_queryable, so the query above
781 + // misses it — and pages are exactly what a warm cache wants most.
782 + // Only add it when the site has post types at all: an empty list
783 + // means there is nothing to enumerate, and constructing a WP_Query
784 + // for it would be wasted work.
785 + if ( ! empty( $types ) ) {
786 + $types['page'] = 'page';
787 + unset( $types['attachment'] );
788 + }
789 +
790 + if ( empty( $types ) || ! class_exists( '\WP_Query' ) ) {
791 + return $urls;
792 + }
793 +
794 + $query = new \WP_Query(
795 + array(
796 + 'post_type' => array_values( $types ),
797 + 'post_status' => 'publish',
798 + 'posts_per_page' => self::FALLBACK_LIMIT,
799 + 'orderby' => 'modified',
800 + 'order' => 'DESC',
801 + 'ignore_sticky_posts' => true,
802 + 'no_found_rows' => true,
803 + 'update_post_meta_cache' => false,
804 + 'update_post_term_cache' => false,
805 + 'fields' => 'ids',
806 + )
807 + );
808 +
809 + foreach ( $query->posts as $post_id ) {
810 + $permalink = get_permalink( (int) $post_id );
811 + if ( is_string( $permalink ) && '' !== $permalink ) {
812 + $urls[] = $permalink;
813 + }
814 + }
815 +
816 + return array_values( array_unique( $urls ) );
817 + }
818 +
819 + /**
297 820 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
298 821 * deep index can't stack-overflow.
299 822 */
300 823 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
@@ -305,12 +828,40 @@
305 828 $sitemap_url,
306 829 array(
307 830 'timeout' => self::REQUEST_TIMEOUT,
308 831 'sslverify' => false,
309 - 'user-agent' => self::USER_AGENT,
832 + 'user-agent' => self::user_agent(),
833 + 'headers' => Self_Traffic::headers(),
310 834 )
311 835 );
312 - if ( is_wp_error( $res ) || (int) wp_remote_retrieve_response_code( $res ) >= 400 ) {
836 + if ( is_wp_error( $res ) ) {
837 + // Record WHY, don't just vanish. "Unreachable" and "valid but
838 + // empty" both used to collapse into an empty array here, which is
839 + // what made a sitemap-less site look like a successful crawl of
840 + // zero URLs. Only the top-level fetch is recorded: a nested index
841 + // failing is a partial result, not a dead crawl. (#142)
842 + if ( 0 === $depth ) {
843 + self::$last_sitemap_error = sprintf(
844 + /* translators: 1: sitemap URL, 2: error detail. */
845 + __( 'Could not fetch the sitemap at %1$s — %2$s', 'xspeed' ),
846 + $sitemap_url,
847 + $res->get_error_message()
848 + );
849 + self::$last_sitemap_url = (string) $sitemap_url;
850 + }
851 + return array();
852 + }
853 + $code = (int) wp_remote_retrieve_response_code( $res );
854 + if ( $code >= 400 ) {
855 + if ( 0 === $depth ) {
856 + self::$last_sitemap_error = sprintf(
857 + /* translators: 1: sitemap URL, 2: HTTP status code. */
858 + __( 'Could not fetch the sitemap at %1$s — the server returned HTTP %2$d.', 'xspeed' ),
859 + $sitemap_url,
860 + $code
861 + );
862 + self::$last_sitemap_url = (string) $sitemap_url;
863 + }
313 864 return array();
314 865 }
315 866 $body = (string) wp_remote_retrieve_body( $res );
316 867 if ( '' === $body ) {