PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.3 1.1.4 1.1.5 All 32 releases
xspeed / includes / class-preloader.php

class-preloader.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.6, at includes/class-preloader.php

814 lines 27.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Preloader — sitemap-driven cache warmer.
4 *
5 * On `start()`:
6 * 1. Fetches the configured sitemap (or auto-detects /wp-sitemap.xml).
7 * 2. Recursively follows nested sitemap indexes.
8 * 3. Filters URLs against the Cache module's excluded_urls list.
9 * 4. Queues the result in a transient.
10 * 5. Schedules the next WP-Cron tick.
11 *
12 * Each `tick()` processes up to `batch_size` URLs from the queue via
13 * `wp_remote_get()` (short timeout, sslverify off for local dev tolerance,
14 * a UA that flags itself so site owners can spot crawler traffic in
15 * access logs). Cache::should_cache() picks up the GET → writes the cache
16 * file on miss. The next visitor sees a HIT.
17 *
18 * State (transient `xspeed_preloader_state`):
19 * { running, started_at, finished_at, queue, processed, total,
20 * last_url, errors[] }
21 *
22 * Configuration comes from PreloaderModule's per-module option
23 * (xspeed_module_preloader) via Settings_Manager.
24 *
25 * @package XSpeed
26 */
27
28 declare(strict_types=1);
29
30 namespace XSpeed;
31
32 defined( 'ABSPATH' ) || exit;
33
34 final class Preloader {
35
36 public const STATE_KEY = 'xspeed_preloader_state';
37 public const STATE_TTL = 86400; // 24h — long enough for slow crawls.
38 public const CRON_HOOK = 'xspeed_preloader_tick';
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)';
55 public const REQUEST_TIMEOUT = 8;
56
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 /**
212 * Kick off a fresh crawl. Returns the initial state.
213 */
214 public static function start(): array {
215 $opts = Settings_Manager::get( 'preloader' );
216 $urls = self::resolve_queue( $opts );
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
239 $state = 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,
254 );
255 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
256
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 );
282
283 // Schedule the first tick ~5 seconds out so the kick-off REST call
284 // returns instantly; wp_schedule_single_event covers the
285 // "process the queue ASAP" path without a heavy synchronous loop.
286 if ( $state['running'] ) {
287 wp_schedule_single_event( time() + 5, self::CRON_HOOK );
288 }
289
290 return $state;
291 }
292
293 /**
294 * Cancel an in-flight crawl. Idempotent.
295 */
296 public static function stop(): array {
297 $state = self::status();
298 if ( $state['running'] ) {
299 Activity_Log::record(
300 'preloader_stopped',
301 sprintf( 'Preloader stopped (%d/%d URLs warmed).', $state['processed'], $state['total'] ),
302 Activity_Log::INFO
303 );
304 }
305
306 // Clear scheduled ticks.
307 wp_clear_scheduled_hook( self::CRON_HOOK );
308
309 $state['running'] = false;
310 $state['finished_at'] = time();
311 $state['queue'] = array();
312 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
313 return $state;
314 }
315
316 public static function status(): array {
317 $raw = get_transient( self::STATE_KEY );
318 if ( ! is_array( $raw ) ) {
319 return self::empty_state();
320 }
321 return wp_parse_args( $raw, self::empty_state() );
322 }
323
324 private static function empty_state(): array {
325 return array(
326 'running' => false,
327 'started_at' => 0,
328 'finished_at' => 0,
329 'queue' => array(),
330 'processed' => 0,
331 'total' => 0,
332 'last_url' => '',
333 'errors' => array(),
334 );
335 }
336
337 /**
338 * Tick handler — pulls up to batch_size URLs off the queue, warms
339 * each, persists state, and reschedules itself until the queue is
340 * empty. Called via the xspeed_preloader_tick action.
341 */
342 public static function tick(): void {
343 $state = self::status();
344 if ( ! $state['running'] || empty( $state['queue'] ) ) {
345 if ( $state['running'] ) {
346 self::mark_complete( $state );
347 }
348 return;
349 }
350
351 $opts = Settings_Manager::get( 'preloader' );
352 $batch = max( 1, min( 50, (int) ( $opts['batch_size'] ?? 5 ) ) );
353
354 $processed_this_tick = 0;
355 while ( $processed_this_tick < $batch && ! empty( $state['queue'] ) ) {
356 $url = array_shift( $state['queue'] );
357 self::warm_url( $url, $state );
358 $state['processed']++;
359 $state['last_url'] = $url;
360 $processed_this_tick++;
361 }
362
363 if ( empty( $state['queue'] ) ) {
364 self::mark_complete( $state );
365 return;
366 }
367
368 // More to do — persist + reschedule. Slight delay to avoid
369 // hammering the origin with parallel batches.
370 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
371 wp_schedule_single_event( time() + 10, self::CRON_HOOK );
372 }
373
374 /**
375 * Fire a single warm request for one URL with no queue / no cron
376 * (the "content warmer" path: new post published → warm its URL
377 * immediately). Records an activity event so the user can see in
378 * the Health log that warming happened.
379 *
380 * Best-effort and non-blocking-feeling — uses a short timeout so a
381 * dead origin can't hang the calling request. Returns true if the
382 * fetch completed with a non-error status, false otherwise.
383 */
384 public static function warm_one( string $url, string $cause = 'manual' ): bool {
385 if ( '' === $url ) {
386 return false;
387 }
388 $response = wp_remote_get(
389 $url,
390 array(
391 'timeout' => self::REQUEST_TIMEOUT,
392 'sslverify' => false,
393 'user-agent' => self::user_agent(),
394 'blocking' => true,
395 )
396 );
397 if ( is_wp_error( $response ) ) {
398 Activity_Log::record(
399 'preloader_warm_failed',
400 sprintf( 'Warm %s failed (%s): %s', $cause, $url, $response->get_error_message() ),
401 Activity_Log::WARN
402 );
403 return false;
404 }
405 $code = (int) wp_remote_retrieve_response_code( $response );
406 if ( $code >= 400 ) {
407 Activity_Log::record(
408 'preloader_warm_failed',
409 sprintf( 'Warm %s failed (%s): %s', $cause, $url, self::failure_detail( $code ) ),
410 Activity_Log::WARN
411 );
412 if ( self::is_firewall_block( $code ) ) {
413 self::remember_firewall_block( $url, $code );
414 }
415 return false;
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();
420 Activity_Log::record(
421 'preloader_warmed_one',
422 sprintf( 'Warmed %s (%s)', $url, $cause ),
423 Activity_Log::INFO
424 );
425 return true;
426 }
427
428 private static function warm_url( string $url, array &$state ): void {
429 $response = wp_remote_get(
430 $url,
431 array(
432 'timeout' => self::REQUEST_TIMEOUT,
433 'sslverify' => false,
434 'user-agent' => self::user_agent(),
435 'headers' => array(
436 'Accept' => 'text/html,application/xhtml+xml',
437 ),
438 'blocking' => true,
439 )
440 );
441 if ( is_wp_error( $response ) ) {
442 $state['errors'][] = array(
443 'url' => $url,
444 'error' => $response->get_error_message(),
445 'ts' => time(),
446 );
447 // Cap retained errors so a broken sitemap doesn't blow the
448 // transient size.
449 $state['errors'] = array_slice( $state['errors'], -20 );
450 return;
451 }
452 $code = (int) wp_remote_retrieve_response_code( $response );
453 if ( $code >= 400 ) {
454 $state['errors'][] = array(
455 'url' => $url,
456 'error' => self::failure_detail( $code ),
457 'ts' => time(),
458 );
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;
464 }
465
466 self::clear_firewall_block();
467 self::warm_remote_dimensions( (string) wp_remote_retrieve_body( $response ) );
468 }
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
558 private static function mark_complete( array $state ): void {
559 $state['running'] = false;
560 $state['finished_at'] = time();
561 $state['queue'] = array();
562 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
563
564 Activity_Log::record(
565 'preloader_completed',
566 sprintf(
567 'Preloader finished — %d/%d URLs warmed, %d error%s.',
568 $state['processed'],
569 $state['total'],
570 count( $state['errors'] ),
571 1 === count( $state['errors'] ) ? '' : 's'
572 ),
573 empty( $state['errors'] ) ? Activity_Log::SUCCESS : Activity_Log::WARN
574 );
575 }
576
577 /**
578 * Build the URL queue for a fresh crawl: parse the sitemap, follow
579 * nested indexes, drop excluded paths.
580 *
581 * @return string[]
582 */
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
590 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
591 if ( '' === $sitemap ) {
592 $sitemap = home_url( '/wp-sitemap.xml' );
593 }
594
595 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
596 $from = empty( $urls ) ? 'none' : 'sitemap';
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
616 $cache_opts = Settings_Manager::get( 'cache' );
617 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
618 if ( ! empty( $excluded ) ) {
619 $urls = array_filter(
620 $urls,
621 static function ( $u ) use ( $excluded ) {
622 $path = (string) wp_parse_url( $u, PHP_URL_PATH );
623 foreach ( $excluded as $needle ) {
624 if ( '' !== $needle && false !== strpos( $path, (string) $needle ) ) {
625 return false;
626 }
627 }
628 return true;
629 }
630 );
631 }
632
633 // Dedup + cap at 5000 to bound the transient size on huge sites.
634 $urls = array_values( array_unique( $urls ) );
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;
649 }
650
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 /**
717 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
718 * deep index can't stack-overflow.
719 */
720 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
721 if ( $depth > 3 ) {
722 return array();
723 }
724 $res = wp_remote_get(
725 $sitemap_url,
726 array(
727 'timeout' => self::REQUEST_TIMEOUT,
728 'sslverify' => false,
729 'user-agent' => self::user_agent(),
730 )
731 );
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 }
760 return array();
761 }
762 $body = (string) wp_remote_retrieve_body( $res );
763 if ( '' === $body ) {
764 return array();
765 }
766
767 $urls = array();
768 // Sitemap index → recurse.
769 if ( false !== strpos( $body, '<sitemapindex' ) ) {
770 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
771 foreach ( $matches[1] as $child ) {
772 $urls = array_merge( $urls, self::fetch_sitemap_urls( trim( $child ), $depth + 1 ) );
773 }
774 }
775 return $urls;
776 }
777 // URL set → collect.
778 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
779 foreach ( $matches[1] as $u ) {
780 $u = trim( $u );
781 if ( '' !== $u && false !== filter_var( $u, FILTER_VALIDATE_URL ) ) {
782 $urls[] = $u;
783 }
784 }
785 }
786 return $urls;
787 }
788
789 /**
790 * Apply the user's schedule choice. Called on settings change.
791 * Manual = no cron schedule (user must hit "Start now" to crawl).
792 */
793 public static function apply_schedule( string $schedule ): void {
794 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
795 if ( in_array( $schedule, array( 'hourly', 'daily', 'weekly' ), true ) ) {
796 if ( ! wp_next_scheduled( 'xspeed_preloader_recurring' ) ) {
797 wp_schedule_event( time() + 60, $schedule, 'xspeed_preloader_recurring' );
798 }
799 }
800 }
801
802 /**
803 * Recurring schedule hook handler — fires per the user's chosen
804 * cadence and kicks off a fresh crawl unless one is already running.
805 */
806 public static function recurring_kickoff(): void {
807 $state = self::status();
808 if ( $state['running'] ) {
809 return;
810 }
811 self::start();
812 }
813 }
814