PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.5
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.5
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 1.1.3 1.1.4 1.1.5 1.1.6 All 31 releases
← All changes | includes/class-preloader.php +468 -21 1.0.31.3.5 View file →
@@ -35,12 +35,181 @@
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 + * Is this status code the signature of a firewall refusing our warmer?
96 + *
97 + * 403 and 406 are what bad-bot rules (7G/8G, mod_security, Wordfence)
98 + * answer with. We only ever warm our OWN origin, and a page a visitor can
99 + * load must be loadable by us too — so these codes mean the request was
100 + * judged by its user-agent, not that the page is missing or broken. (#481)
101 + */
102 + private static function is_firewall_block( int $code ): bool {
103 + return in_array( $code, array( 403, 406 ), true );
104 + }
105 +
106 + /**
107 + * Explain a warm failure in terms the admin can act on.
108 + *
109 + * A bare "HTTP 403" sent people hunting a broken page; the page is fine,
110 + * and the fix is a server rule, so the message has to name the cause and
111 + * the exact UA to allow. (#481)
112 + */
113 + private static function failure_detail( int $code ): string {
114 + if ( ! self::is_firewall_block( $code ) ) {
115 + return sprintf( 'HTTP %d', $code );
116 + }
117 +
118 + return sprintf(
119 + '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.',
120 + $code,
121 + self::user_agent()
122 + );
123 + }
124 +
125 + /** Option holding the last firewall-shaped warm refusal. */
126 + public const FIREWALL_BLOCK_OPTION = 'xspeed_preloader_firewall_block';
127 +
128 + /**
129 + * Record that the origin refused a warm by user-agent, for ui_notices().
130 + *
131 + * An option rather than a transient: the condition is a server rule that
132 + * persists until someone changes it, and a notice that expired on its own
133 + * would let a site go back to never warming, silently. Cleared by
134 + * clear_firewall_block() on the first warm that succeeds. (#481)
135 + */
136 + private static function remember_firewall_block( string $url, int $code ): void {
137 + if ( ! function_exists( 'update_option' ) ) {
138 + return;
139 + }
140 + update_option(
141 + self::FIREWALL_BLOCK_OPTION,
142 + array(
143 + 'url' => $url,
144 + 'code' => $code,
145 + 'user_agent' => self::user_agent(),
146 + 'ts' => time(),
147 + ),
148 + false
149 + );
150 + }
151 +
152 + /** Forget the firewall block once a warm gets through. */
153 + public static function clear_firewall_block(): void {
154 + if ( function_exists( 'delete_option' ) && self::firewall_block() ) {
155 + delete_option( self::FIREWALL_BLOCK_OPTION );
156 + }
157 + }
158 +
159 + /** The last firewall-shaped refusal, or null when there isn't one. */
160 + public static function firewall_block(): ?array {
161 + if ( ! function_exists( 'get_option' ) ) {
162 + return null;
163 + }
164 + $block = get_option( self::FIREWALL_BLOCK_OPTION, null );
165 +
166 + return ( is_array( $block ) && ! empty( $block['code'] ) ) ? $block : null;
167 + }
168 +
169 + /**
170 + * How many new remote images one warmed page may resolve.
171 + */
172 + private static function remote_dimension_limit(): int {
173 + /**
174 + * Filter the per-page cap on remote dimension lookups.
175 + *
176 + * @param int $limit Default 20. Values below 1 disable the lookup.
177 + */
178 + return (int) apply_filters( 'xspeed_preloader_remote_dimension_limit', self::REMOTE_DIMENSION_LIMIT );
179 + }
180 +
181 + /**
182 + * Why the top-level sitemap fetch failed on this request, or '' when it
183 + * succeeded. Set by fetch_sitemap_urls(), read by resolve_queue() — the
184 + * reason has to survive the return of an empty array, which is exactly
185 + * what it could not do before. Request-scoped; never persisted. (#142)
186 + *
187 + * @var string
188 + */
189 + private static $last_sitemap_error = '';
190 +
191 + /**
192 + * The sitemap URL the last error refers to. Kept beside the message so
193 + * an error entry can carry a real `url` field like every other one,
194 + * rather than repeating the URL already inside the message text.
195 + */
196 + private static $last_sitemap_url = '';
197 +
198 + /**
199 + * How the queue for the current crawl was built — 'sitemap', 'fallback'
200 + * (enumerated from the database because the sitemap was unreachable), or
201 + * 'none'. Surfaced in the state so the panel, REST and CLI can each say
202 + * what actually happened instead of reporting a bare zero. (#142)
203 + *
204 + * @var string
205 + */
206 + private static $queue_source = 'none';
207 +
208 + /** Largest number of URLs the database fallback will enumerate. */
209 + private const FALLBACK_LIMIT = 500;
210 +
211 + /**
43 212 * Kick off a fresh crawl. Returns the initial state.
44 213 */
45 214 public static function start(): array {
46 215 $opts = Settings_Manager::get( 'preloader' );
@@ -45,25 +214,72 @@
45 214 public static function start(): array {
46 215 $opts = Settings_Manager::get( 'preloader' );
47 216 $urls = self::resolve_queue( $opts );
48 217
218 + // A crawl that queued nothing because the sitemap was unreachable is a
219 + // FAILURE, and every layer above needs to be able to say so. It used
220 + // to be indistinguishable from success: errors stayed empty, the REST
221 + // route returned 200, and the CLI printed a green Success. (#142)
222 + $sitemap_error = self::$last_sitemap_error;
223 + $errors = array();
224 + if ( '' !== $sitemap_error && empty( $urls ) ) {
225 + // Same {url, error, ts} shape every other entry uses. A bare
226 + // string here fataled `wp xspeed preloader status`, which
227 + // destructures `$e['url']` over the list — and took the MCP
228 + // `get_preloader_status` tool down with it, so an agent asking
229 + // why the preload failed got "Cannot access offset of type
230 + // string on string" instead of the reason this code records.
231 + // `url` is the sitemap because that is what failed. (QA F1)
232 + $errors[] = array(
233 + 'url' => self::$last_sitemap_url,
234 + 'error' => $sitemap_error,
235 + 'ts' => time(),
236 + );
237 + }
238 +
49 239 $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(),
240 + 'running' => ! empty( $urls ),
241 + 'started_at' => time(),
242 + 'finished_at' => empty( $urls ) ? time() : 0,
243 + 'queue' => array_values( $urls ),
244 + 'processed' => 0,
245 + 'total' => count( $urls ),
246 + 'last_url' => '',
247 + 'errors' => $errors,
248 + // Consumers render on these: the panel needs to distinguish
249 + // "not started" from "ran and found nothing", and to tell the
250 + // user when the queue came from the fallback rather than the
251 + // sitemap they configured.
252 + 'source' => self::$queue_source,
253 + 'sitemap_error' => $sitemap_error,
58 254 );
59 255 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
60 256
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 - );
257 + if ( '' !== $sitemap_error && 'fallback' === self::$queue_source ) {
258 + $message = sprintf(
259 + /* translators: 1: number of URLs, 2: the sitemap failure detail. */
260 + __( 'Preloader queued %1$d URLs from the site content — %2$s', 'xspeed' ),
261 + $state['total'],
262 + $sitemap_error
263 + );
264 + $severity = Activity_Log::WARN;
265 + } elseif ( '' !== $sitemap_error ) {
266 + $message = sprintf(
267 + /* translators: %s: the sitemap failure detail. */
268 + __( 'Preloader could not start — %s', 'xspeed' ),
269 + $sitemap_error
270 + );
271 + $severity = Activity_Log::WARN;
272 + } else {
273 + $message = sprintf(
274 + /* translators: 1: number of URLs, 2: plural suffix. */
275 + __( 'Preloader queued %1$d URL%2$s for warming.', 'xspeed' ),
276 + $state['total'],
277 + 1 === $state['total'] ? '' : 's'
278 + );
279 + $severity = $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN;
280 + }
281 + Activity_Log::record( 'preloader_started', $message, $severity );
66 282
67 283 // Schedule the first tick ~5 seconds out so the kick-off REST call
68 284 // returns instantly; wp_schedule_single_event covers the
69 285 // "process the queue ASAP" path without a heavy synchronous loop.
@@ -173,9 +389,9 @@
173 389 $url,
174 390 array(
175 391 'timeout' => self::REQUEST_TIMEOUT,
176 392 'sslverify' => false,
177 - 'user-agent' => self::USER_AGENT,
393 + 'user-agent' => self::user_agent(),
178 394 'blocking' => true,
179 395 )
180 396 );
181 397 if ( is_wp_error( $response ) ) {
@@ -189,13 +405,19 @@
189 405 $code = (int) wp_remote_retrieve_response_code( $response );
190 406 if ( $code >= 400 ) {
191 407 Activity_Log::record(
192 408 'preloader_warm_failed',
193 - sprintf( 'Warm %s failed (%s): HTTP %d', $cause, $url, $code ),
409 + sprintf( 'Warm %s failed (%s): %s', $cause, $url, self::failure_detail( $code ) ),
194 410 Activity_Log::WARN
195 411 );
412 + if ( self::is_firewall_block( $code ) ) {
413 + self::remember_firewall_block( $url, $code );
414 + }
196 415 return false;
197 416 }
417 + // A warm that got through proves the firewall is no longer refusing us,
418 + // so the notice must go — otherwise it outlives the problem. (#481)
419 + self::clear_firewall_block();
198 420 Activity_Log::record(
199 421 'preloader_warmed_one',
200 422 sprintf( 'Warmed %s (%s)', $url, $cause ),
201 423 Activity_Log::INFO
@@ -208,9 +430,9 @@
208 430 $url,
209 431 array(
210 432 'timeout' => self::REQUEST_TIMEOUT,
211 433 'sslverify' => false,
212 - 'user-agent' => self::USER_AGENT,
434 + 'user-agent' => self::user_agent(),
213 435 'headers' => array(
214 436 'Accept' => 'text/html,application/xhtml+xml',
215 437 ),
216 438 'blocking' => true,
@@ -230,15 +452,110 @@
230 452 $code = (int) wp_remote_retrieve_response_code( $response );
231 453 if ( $code >= 400 ) {
232 454 $state['errors'][] = array(
233 455 'url' => $url,
234 - 'error' => sprintf( 'HTTP %d', $code ),
456 + 'error' => self::failure_detail( $code ),
235 457 'ts' => time(),
236 458 );
237 459 $state['errors'] = array_slice( $state['errors'], -20 );
460 + if ( self::is_firewall_block( $code ) ) {
461 + self::remember_firewall_block( $url, $code );
462 + }
463 + return;
238 464 }
465 +
466 + self::clear_firewall_block();
467 + self::warm_remote_dimensions( (string) wp_remote_retrieve_body( $response ) );
239 468 }
240 469
470 + /**
471 + * Resolve dimensions for externally hosted images found on a warmed page.
472 + *
473 + * The crawl already has the HTML in hand, so harvesting image URLs from it
474 + * costs nothing extra — and this is the one place where paying for a
475 + * remote lookup is free of consequence, because no visitor is waiting.
476 + *
477 + * An image on another domain has no local file to measure, so the front
478 + * end skips it and the page ships without width/height — which is layout
479 + * shift, on precisely the sites least able to fix it by hand (a CDN, a
480 + * sister site, a shared asset host). Warming here means the NEXT render
481 + * finds the dimensions in cache and stamps them, with the visitor paying
482 + * nothing.
483 + *
484 + * Deliberately bounded per page: a crawl should not turn into a scraper
485 + * for a page embedding hundreds of third-party images.
486 + *
487 + * @param string $html The warmed page's HTML.
488 + */
489 + private static function warm_remote_dimensions( string $html ): void {
490 + if ( '' === $html || ! class_exists( '\XSpeed\Lazy_Loader' ) ) {
491 + return;
492 + }
493 +
494 + $opts = Settings_Manager::get( 'lazy' );
495 + if ( empty( $opts['add_missing_dimensions'] ) ) {
496 + return;
497 + }
498 +
499 + // Match any <img>, not only one carrying `src`. The URL worth warming
500 + // may live in a lazy attribute instead — which is the whole point of
501 + // #328 — and resolvable_image_url() below is what knows where to look.
502 + if ( ! preg_match_all( '#<img\b[^>]*>#i', $html, $m, PREG_SET_ORDER ) ) {
503 + return;
504 + }
505 +
506 + $home = wp_parse_url( home_url(), PHP_URL_HOST );
507 + $targets = array();
508 + foreach ( $m as $tag ) {
509 + // Only tags MISSING a dimension are worth resolving — one that
510 + // already declares both needs nothing.
511 + // Same lookbehind as Lazy_Loader::ensure_dimensions(): a bare
512 + // `\bwidth=` also matches `data-width=`, so a slider carrying its
513 + // own metadata looked already-sized and was skipped from warming.
514 + // The two must agree, or the collector skips exactly the tags the
515 + // renderer still needs measured. (#333 review round 3, issue 2)
516 + if ( preg_match( '#(?<![-\w])width\s*=#i', $tag[0] ) && preg_match( '#(?<![-\w])height\s*=#i', $tag[0] ) ) {
517 + continue;
518 + }
519 + // Ask the same resolver the render path uses, rather than reading
520 + // `src` directly. A slider parks a spacer in `src` and the real
521 + // URL in `data-lazy`/`data-src`/`data-original`, so a collector
522 + // looking only at `src` warmed the SPACER and never the image —
523 + // leaving remotely-hosted slider images unresolvable at render
524 + // time, the exact markup #328 is about. (#333 review round 2,
525 + // issue 3)
526 + // `false`: do not let the resolver settle a name-refused URL by
527 + // MEASURING it. That is circular here — remote measurement is
528 + // gated until warm_dimensions() sets $warming, and this collector
529 + // is what feeds warm_dimensions(). Take the URL the tag offers and
530 + // let the warm pass decide. (#333 review round 3, issue 3)
531 + $src = Lazy_Loader::resolvable_image_url( $tag[0], false );
532 + if ( '' === $src || ! preg_match( '#^https?://#i', $src ) ) {
533 + continue;
534 + }
535 + $host = wp_parse_url( $src, PHP_URL_HOST );
536 + if ( ! $host || $host === $home ) {
537 + continue; // Local images already resolve from disk.
538 + }
539 + // Already resolved (or already known unresolvable) — looking it up
540 + // again costs a request and teaches us nothing. Skipping it is
541 + // also what makes the cap below advance: images appear in the same
542 + // DOM order every crawl, so a collector that did not skip would
543 + // re-pick the same first N for ever and never reach the rest.
544 + if ( Lazy_Loader::dimensions_known( $src ) ) {
545 + continue;
546 + }
547 + $targets[ $src ] = true;
548 + if ( count( $targets ) >= self::remote_dimension_limit() ) {
549 + break;
550 + }
551 + }
552 +
553 + if ( $targets ) {
554 + Lazy_Loader::warm_dimensions( array_keys( $targets ) );
555 + }
556 + }
557 +
241 558 private static function mark_complete( array $state ): void {
242 559 $state['running'] = false;
243 560 $state['finished_at'] = time();
244 561 $state['queue'] = array();
@@ -263,8 +580,14 @@
263 580 *
264 581 * @return string[]
265 582 */
266 583 private static function resolve_queue( array $opts ): array {
584 + // Request-scoped statics: reset so a previous crawl in the same
585 + // process can't leak its verdict into this one.
586 + self::$last_sitemap_error = '';
587 + self::$last_sitemap_url = '';
588 + self::$queue_source = 'none';
589 +
267 590 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
268 591 if ( '' === $sitemap ) {
269 592 $sitemap = home_url( '/wp-sitemap.xml' );
270 593 }
@@ -269,9 +592,28 @@
269 592 $sitemap = home_url( '/wp-sitemap.xml' );
270 593 }
271 594
272 595 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
596 + $from = empty( $urls ) ? 'none' : 'sitemap';
273 597
598 + /*
599 + * A missing sitemap must not disable the feature. Two very common
600 + * setups produce one with no misconfiguration by the user:
601 + * `blog_public = 0` (WordPress disables /wp-sitemap.xml outright,
602 + * standard on staging and pre-launch sites), and an SEO plugin
603 + * filtering `wp_sitemaps_enabled` to false while serving its own
604 + * sitemap at a path we were never told about.
605 + *
606 + * Enumerate warmable URLs straight from the database instead. Only
607 + * on a genuine fetch FAILURE — a sitemap that is reachable and
608 + * legitimately empty is a real answer, and silently crawling
609 + * something else would be worse than doing nothing. (#142)
610 + */
611 + if ( empty( $urls ) && '' !== self::$last_sitemap_error ) {
612 + $urls = self::fallback_urls();
613 + $from = empty( $urls ) ? 'none' : 'fallback';
614 + }
615 +
274 616 $cache_opts = Settings_Manager::get( 'cache' );
275 617 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
276 618 if ( ! empty( $excluded ) ) {
277 619 $urls = array_filter(
@@ -289,12 +631,90 @@
289 631 }
290 632
291 633 // Dedup + cap at 5000 to bound the transient size on huge sites.
292 634 $urls = array_values( array_unique( $urls ) );
293 - return array_slice( $urls, 0, 5000 );
635 + $urls = array_slice( $urls, 0, 5000 );
636 +
637 + /*
638 + * Commit the verdict only now, AFTER the exclusion filter — the
639 + * source describes what we ACTUALLY queued, not what we hoped to.
640 + * Setting it earlier let a queue that the exclusions stripped to
641 + * nothing still claim `fallback`, so the panel announced "Warmed
642 + * from site content" over 0 URLs, and `crawlFailed` (which needs
643 + * source !== 'fallback' at total 0) could never become true.
644 + * One assignment fixes both. (QA F2/F3 on #155)
645 + */
646 + self::$queue_source = empty( $urls ) ? 'none' : $from;
647 +
648 + return $urls;
294 649 }
295 650
296 651 /**
652 + * Enumerate warmable URLs from the database, for sites whose sitemap
653 + * can't be fetched. Deliberately modest in scope: the home page, then
654 + * the most recently modified public posts across every public post type.
655 + * Newest-first is the right bias — those are the URLs most likely to be
656 + * requested and least likely to be warm already.
657 + *
658 + * Uses WP_Query rather than SQL so post-type registration, status
659 + * handling and multisite switching all behave the way the rest of
660 + * WordPress does.
661 + *
662 + * @return string[]
663 + */
664 + private static function fallback_urls(): array {
665 + $urls = array();
666 + $home = (string) home_url( '/' );
667 + if ( '' !== trim( $home, '/' ) ) {
668 + $urls[] = $home;
669 + }
670 +
671 + $types = get_post_types(
672 + array(
673 + 'public' => true,
674 + 'publicly_queryable' => true,
675 + )
676 + );
677 + // `page` is public but not publicly_queryable, so the query above
678 + // misses it — and pages are exactly what a warm cache wants most.
679 + // Only add it when the site has post types at all: an empty list
680 + // means there is nothing to enumerate, and constructing a WP_Query
681 + // for it would be wasted work.
682 + if ( ! empty( $types ) ) {
683 + $types['page'] = 'page';
684 + unset( $types['attachment'] );
685 + }
686 +
687 + if ( empty( $types ) || ! class_exists( '\WP_Query' ) ) {
688 + return $urls;
689 + }
690 +
691 + $query = new \WP_Query(
692 + array(
693 + 'post_type' => array_values( $types ),
694 + 'post_status' => 'publish',
695 + 'posts_per_page' => self::FALLBACK_LIMIT,
696 + 'orderby' => 'modified',
697 + 'order' => 'DESC',
698 + 'ignore_sticky_posts' => true,
699 + 'no_found_rows' => true,
700 + 'update_post_meta_cache' => false,
701 + 'update_post_term_cache' => false,
702 + 'fields' => 'ids',
703 + )
704 + );
705 +
706 + foreach ( $query->posts as $post_id ) {
707 + $permalink = get_permalink( (int) $post_id );
708 + if ( is_string( $permalink ) && '' !== $permalink ) {
709 + $urls[] = $permalink;
710 + }
711 + }
712 +
713 + return array_values( array_unique( $urls ) );
714 + }
715 +
716 + /**
297 717 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
298 718 * deep index can't stack-overflow.
299 719 */
300 720 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
@@ -305,12 +725,39 @@
305 725 $sitemap_url,
306 726 array(
307 727 'timeout' => self::REQUEST_TIMEOUT,
308 728 'sslverify' => false,
309 - 'user-agent' => self::USER_AGENT,
729 + 'user-agent' => self::user_agent(),
310 730 )
311 731 );
312 - if ( is_wp_error( $res ) || (int) wp_remote_retrieve_response_code( $res ) >= 400 ) {
732 + if ( is_wp_error( $res ) ) {
733 + // Record WHY, don't just vanish. "Unreachable" and "valid but
734 + // empty" both used to collapse into an empty array here, which is
735 + // what made a sitemap-less site look like a successful crawl of
736 + // zero URLs. Only the top-level fetch is recorded: a nested index
737 + // failing is a partial result, not a dead crawl. (#142)
738 + if ( 0 === $depth ) {
739 + self::$last_sitemap_error = sprintf(
740 + /* translators: 1: sitemap URL, 2: error detail. */
741 + __( 'Could not fetch the sitemap at %1$s — %2$s', 'xspeed' ),
742 + $sitemap_url,
743 + $res->get_error_message()
744 + );
745 + self::$last_sitemap_url = (string) $sitemap_url;
746 + }
747 + return array();
748 + }
749 + $code = (int) wp_remote_retrieve_response_code( $res );
750 + if ( $code >= 400 ) {
751 + if ( 0 === $depth ) {
752 + self::$last_sitemap_error = sprintf(
753 + /* translators: 1: sitemap URL, 2: HTTP status code. */
754 + __( 'Could not fetch the sitemap at %1$s — the server returned HTTP %2$d.', 'xspeed' ),
755 + $sitemap_url,
756 + $code
757 + );
758 + self::$last_sitemap_url = (string) $sitemap_url;
759 + }
313 760 return array();
314 761 }
315 762 $body = (string) wp_remote_retrieve_body( $res );
316 763 if ( '' === $body ) {