PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.1.7 1.1.8 1.2.0 1.2.1 All 27 releases
xspeed / includes / class-preloader.php

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

695 lines 23.0 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 public const USER_AGENT = 'xSpeed-Preloader/1.0 (+cache warmer; admin-initiated)';
40 public const REQUEST_TIMEOUT = 8;
41
42 /**
43 * Default cap on NEW remote images resolved per warmed page.
44 *
45 * A crawl warms the cache; it is not a licence to hit third-party hosts
46 * hundreds of times for one page.
47 *
48 * The cap counts only images whose dimensions are not already known, and
49 * results persist between runs — so each crawl advances through a heavily
50 * embedded page rather than re-picking the same first N. That is what
51 * makes a cap safe here: without the skip it would strand everything past
52 * the limit permanently, because images appear in the same DOM order
53 * every time.
54 *
55 * 20 is a starting point, not a measurement. Sites that embed more can
56 * raise it via `xspeed_preloader_remote_dimension_limit`.
57 */
58 private const REMOTE_DIMENSION_LIMIT = 20;
59
60 /**
61 * How many new remote images one warmed page may resolve.
62 */
63 private static function remote_dimension_limit(): int {
64 /**
65 * Filter the per-page cap on remote dimension lookups.
66 *
67 * @param int $limit Default 20. Values below 1 disable the lookup.
68 */
69 return (int) apply_filters( 'xspeed_preloader_remote_dimension_limit', self::REMOTE_DIMENSION_LIMIT );
70 }
71
72 /**
73 * Why the top-level sitemap fetch failed on this request, or '' when it
74 * succeeded. Set by fetch_sitemap_urls(), read by resolve_queue() — the
75 * reason has to survive the return of an empty array, which is exactly
76 * what it could not do before. Request-scoped; never persisted. (#142)
77 *
78 * @var string
79 */
80 private static $last_sitemap_error = '';
81
82 /**
83 * The sitemap URL the last error refers to. Kept beside the message so
84 * an error entry can carry a real `url` field like every other one,
85 * rather than repeating the URL already inside the message text.
86 */
87 private static $last_sitemap_url = '';
88
89 /**
90 * How the queue for the current crawl was built — 'sitemap', 'fallback'
91 * (enumerated from the database because the sitemap was unreachable), or
92 * 'none'. Surfaced in the state so the panel, REST and CLI can each say
93 * what actually happened instead of reporting a bare zero. (#142)
94 *
95 * @var string
96 */
97 private static $queue_source = 'none';
98
99 /** Largest number of URLs the database fallback will enumerate. */
100 private const FALLBACK_LIMIT = 500;
101
102 /**
103 * Kick off a fresh crawl. Returns the initial state.
104 */
105 public static function start(): array {
106 $opts = Settings_Manager::get( 'preloader' );
107 $urls = self::resolve_queue( $opts );
108
109 // A crawl that queued nothing because the sitemap was unreachable is a
110 // FAILURE, and every layer above needs to be able to say so. It used
111 // to be indistinguishable from success: errors stayed empty, the REST
112 // route returned 200, and the CLI printed a green Success. (#142)
113 $sitemap_error = self::$last_sitemap_error;
114 $errors = array();
115 if ( '' !== $sitemap_error && empty( $urls ) ) {
116 // Same {url, error, ts} shape every other entry uses. A bare
117 // string here fataled `wp xspeed preloader status`, which
118 // destructures `$e['url']` over the list — and took the MCP
119 // `get_preloader_status` tool down with it, so an agent asking
120 // why the preload failed got "Cannot access offset of type
121 // string on string" instead of the reason this code records.
122 // `url` is the sitemap because that is what failed. (QA F1)
123 $errors[] = array(
124 'url' => self::$last_sitemap_url,
125 'error' => $sitemap_error,
126 'ts' => time(),
127 );
128 }
129
130 $state = array(
131 'running' => ! empty( $urls ),
132 'started_at' => time(),
133 'finished_at' => empty( $urls ) ? time() : 0,
134 'queue' => array_values( $urls ),
135 'processed' => 0,
136 'total' => count( $urls ),
137 'last_url' => '',
138 'errors' => $errors,
139 // Consumers render on these: the panel needs to distinguish
140 // "not started" from "ran and found nothing", and to tell the
141 // user when the queue came from the fallback rather than the
142 // sitemap they configured.
143 'source' => self::$queue_source,
144 'sitemap_error' => $sitemap_error,
145 );
146 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
147
148 if ( '' !== $sitemap_error && 'fallback' === self::$queue_source ) {
149 $message = sprintf(
150 /* translators: 1: number of URLs, 2: the sitemap failure detail. */
151 __( 'Preloader queued %1$d URLs from the site content — %2$s', 'xspeed' ),
152 $state['total'],
153 $sitemap_error
154 );
155 $severity = Activity_Log::WARN;
156 } elseif ( '' !== $sitemap_error ) {
157 $message = sprintf(
158 /* translators: %s: the sitemap failure detail. */
159 __( 'Preloader could not start — %s', 'xspeed' ),
160 $sitemap_error
161 );
162 $severity = Activity_Log::WARN;
163 } else {
164 $message = sprintf(
165 /* translators: 1: number of URLs, 2: plural suffix. */
166 __( 'Preloader queued %1$d URL%2$s for warming.', 'xspeed' ),
167 $state['total'],
168 1 === $state['total'] ? '' : 's'
169 );
170 $severity = $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN;
171 }
172 Activity_Log::record( 'preloader_started', $message, $severity );
173
174 // Schedule the first tick ~5 seconds out so the kick-off REST call
175 // returns instantly; wp_schedule_single_event covers the
176 // "process the queue ASAP" path without a heavy synchronous loop.
177 if ( $state['running'] ) {
178 wp_schedule_single_event( time() + 5, self::CRON_HOOK );
179 }
180
181 return $state;
182 }
183
184 /**
185 * Cancel an in-flight crawl. Idempotent.
186 */
187 public static function stop(): array {
188 $state = self::status();
189 if ( $state['running'] ) {
190 Activity_Log::record(
191 'preloader_stopped',
192 sprintf( 'Preloader stopped (%d/%d URLs warmed).', $state['processed'], $state['total'] ),
193 Activity_Log::INFO
194 );
195 }
196
197 // Clear scheduled ticks.
198 wp_clear_scheduled_hook( self::CRON_HOOK );
199
200 $state['running'] = false;
201 $state['finished_at'] = time();
202 $state['queue'] = array();
203 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
204 return $state;
205 }
206
207 public static function status(): array {
208 $raw = get_transient( self::STATE_KEY );
209 if ( ! is_array( $raw ) ) {
210 return self::empty_state();
211 }
212 return wp_parse_args( $raw, self::empty_state() );
213 }
214
215 private static function empty_state(): array {
216 return array(
217 'running' => false,
218 'started_at' => 0,
219 'finished_at' => 0,
220 'queue' => array(),
221 'processed' => 0,
222 'total' => 0,
223 'last_url' => '',
224 'errors' => array(),
225 );
226 }
227
228 /**
229 * Tick handler — pulls up to batch_size URLs off the queue, warms
230 * each, persists state, and reschedules itself until the queue is
231 * empty. Called via the xspeed_preloader_tick action.
232 */
233 public static function tick(): void {
234 $state = self::status();
235 if ( ! $state['running'] || empty( $state['queue'] ) ) {
236 if ( $state['running'] ) {
237 self::mark_complete( $state );
238 }
239 return;
240 }
241
242 $opts = Settings_Manager::get( 'preloader' );
243 $batch = max( 1, min( 50, (int) ( $opts['batch_size'] ?? 5 ) ) );
244
245 $processed_this_tick = 0;
246 while ( $processed_this_tick < $batch && ! empty( $state['queue'] ) ) {
247 $url = array_shift( $state['queue'] );
248 self::warm_url( $url, $state );
249 $state['processed']++;
250 $state['last_url'] = $url;
251 $processed_this_tick++;
252 }
253
254 if ( empty( $state['queue'] ) ) {
255 self::mark_complete( $state );
256 return;
257 }
258
259 // More to do — persist + reschedule. Slight delay to avoid
260 // hammering the origin with parallel batches.
261 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
262 wp_schedule_single_event( time() + 10, self::CRON_HOOK );
263 }
264
265 /**
266 * Fire a single warm request for one URL with no queue / no cron
267 * (the "content warmer" path: new post published → warm its URL
268 * immediately). Records an activity event so the user can see in
269 * the Health log that warming happened.
270 *
271 * Best-effort and non-blocking-feeling — uses a short timeout so a
272 * dead origin can't hang the calling request. Returns true if the
273 * fetch completed with a non-error status, false otherwise.
274 */
275 public static function warm_one( string $url, string $cause = 'manual' ): bool {
276 if ( '' === $url ) {
277 return false;
278 }
279 $response = wp_remote_get(
280 $url,
281 array(
282 'timeout' => self::REQUEST_TIMEOUT,
283 'sslverify' => false,
284 'user-agent' => self::USER_AGENT,
285 'blocking' => true,
286 )
287 );
288 if ( is_wp_error( $response ) ) {
289 Activity_Log::record(
290 'preloader_warm_failed',
291 sprintf( 'Warm %s failed (%s): %s', $cause, $url, $response->get_error_message() ),
292 Activity_Log::WARN
293 );
294 return false;
295 }
296 $code = (int) wp_remote_retrieve_response_code( $response );
297 if ( $code >= 400 ) {
298 Activity_Log::record(
299 'preloader_warm_failed',
300 sprintf( 'Warm %s failed (%s): HTTP %d', $cause, $url, $code ),
301 Activity_Log::WARN
302 );
303 return false;
304 }
305 Activity_Log::record(
306 'preloader_warmed_one',
307 sprintf( 'Warmed %s (%s)', $url, $cause ),
308 Activity_Log::INFO
309 );
310 return true;
311 }
312
313 private static function warm_url( string $url, array &$state ): void {
314 $response = wp_remote_get(
315 $url,
316 array(
317 'timeout' => self::REQUEST_TIMEOUT,
318 'sslverify' => false,
319 'user-agent' => self::USER_AGENT,
320 'headers' => array(
321 'Accept' => 'text/html,application/xhtml+xml',
322 ),
323 'blocking' => true,
324 )
325 );
326 if ( is_wp_error( $response ) ) {
327 $state['errors'][] = array(
328 'url' => $url,
329 'error' => $response->get_error_message(),
330 'ts' => time(),
331 );
332 // Cap retained errors so a broken sitemap doesn't blow the
333 // transient size.
334 $state['errors'] = array_slice( $state['errors'], -20 );
335 return;
336 }
337 $code = (int) wp_remote_retrieve_response_code( $response );
338 if ( $code >= 400 ) {
339 $state['errors'][] = array(
340 'url' => $url,
341 'error' => sprintf( 'HTTP %d', $code ),
342 'ts' => time(),
343 );
344 $state['errors'] = array_slice( $state['errors'], -20 );
345 return;
346 }
347
348 self::warm_remote_dimensions( (string) wp_remote_retrieve_body( $response ) );
349 }
350
351 /**
352 * Resolve dimensions for externally hosted images found on a warmed page.
353 *
354 * The crawl already has the HTML in hand, so harvesting image URLs from it
355 * costs nothing extra — and this is the one place where paying for a
356 * remote lookup is free of consequence, because no visitor is waiting.
357 *
358 * An image on another domain has no local file to measure, so the front
359 * end skips it and the page ships without width/height — which is layout
360 * shift, on precisely the sites least able to fix it by hand (a CDN, a
361 * sister site, a shared asset host). Warming here means the NEXT render
362 * finds the dimensions in cache and stamps them, with the visitor paying
363 * nothing.
364 *
365 * Deliberately bounded per page: a crawl should not turn into a scraper
366 * for a page embedding hundreds of third-party images.
367 *
368 * @param string $html The warmed page's HTML.
369 */
370 private static function warm_remote_dimensions( string $html ): void {
371 if ( '' === $html || ! class_exists( '\XSpeed\Lazy_Loader' ) ) {
372 return;
373 }
374
375 $opts = Settings_Manager::get( 'lazy' );
376 if ( empty( $opts['add_missing_dimensions'] ) ) {
377 return;
378 }
379
380 // Match any <img>, not only one carrying `src`. The URL worth warming
381 // may live in a lazy attribute instead — which is the whole point of
382 // #328 — and resolvable_image_url() below is what knows where to look.
383 if ( ! preg_match_all( '#<img\b[^>]*>#i', $html, $m, PREG_SET_ORDER ) ) {
384 return;
385 }
386
387 $home = wp_parse_url( home_url(), PHP_URL_HOST );
388 $targets = array();
389 foreach ( $m as $tag ) {
390 // Only tags MISSING a dimension are worth resolving — one that
391 // already declares both needs nothing.
392 // Same lookbehind as Lazy_Loader::ensure_dimensions(): a bare
393 // `\bwidth=` also matches `data-width=`, so a slider carrying its
394 // own metadata looked already-sized and was skipped from warming.
395 // The two must agree, or the collector skips exactly the tags the
396 // renderer still needs measured. (#333 review round 3, issue 2)
397 if ( preg_match( '#(?<![-\w])width\s*=#i', $tag[0] ) && preg_match( '#(?<![-\w])height\s*=#i', $tag[0] ) ) {
398 continue;
399 }
400 // Ask the same resolver the render path uses, rather than reading
401 // `src` directly. A slider parks a spacer in `src` and the real
402 // URL in `data-lazy`/`data-src`/`data-original`, so a collector
403 // looking only at `src` warmed the SPACER and never the image —
404 // leaving remotely-hosted slider images unresolvable at render
405 // time, the exact markup #328 is about. (#333 review round 2,
406 // issue 3)
407 // `false`: do not let the resolver settle a name-refused URL by
408 // MEASURING it. That is circular here — remote measurement is
409 // gated until warm_dimensions() sets $warming, and this collector
410 // is what feeds warm_dimensions(). Take the URL the tag offers and
411 // let the warm pass decide. (#333 review round 3, issue 3)
412 $src = Lazy_Loader::resolvable_image_url( $tag[0], false );
413 if ( '' === $src || ! preg_match( '#^https?://#i', $src ) ) {
414 continue;
415 }
416 $host = wp_parse_url( $src, PHP_URL_HOST );
417 if ( ! $host || $host === $home ) {
418 continue; // Local images already resolve from disk.
419 }
420 // Already resolved (or already known unresolvable) — looking it up
421 // again costs a request and teaches us nothing. Skipping it is
422 // also what makes the cap below advance: images appear in the same
423 // DOM order every crawl, so a collector that did not skip would
424 // re-pick the same first N for ever and never reach the rest.
425 if ( Lazy_Loader::dimensions_known( $src ) ) {
426 continue;
427 }
428 $targets[ $src ] = true;
429 if ( count( $targets ) >= self::remote_dimension_limit() ) {
430 break;
431 }
432 }
433
434 if ( $targets ) {
435 Lazy_Loader::warm_dimensions( array_keys( $targets ) );
436 }
437 }
438
439 private static function mark_complete( array $state ): void {
440 $state['running'] = false;
441 $state['finished_at'] = time();
442 $state['queue'] = array();
443 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
444
445 Activity_Log::record(
446 'preloader_completed',
447 sprintf(
448 'Preloader finished — %d/%d URLs warmed, %d error%s.',
449 $state['processed'],
450 $state['total'],
451 count( $state['errors'] ),
452 1 === count( $state['errors'] ) ? '' : 's'
453 ),
454 empty( $state['errors'] ) ? Activity_Log::SUCCESS : Activity_Log::WARN
455 );
456 }
457
458 /**
459 * Build the URL queue for a fresh crawl: parse the sitemap, follow
460 * nested indexes, drop excluded paths.
461 *
462 * @return string[]
463 */
464 private static function resolve_queue( array $opts ): array {
465 // Request-scoped statics: reset so a previous crawl in the same
466 // process can't leak its verdict into this one.
467 self::$last_sitemap_error = '';
468 self::$last_sitemap_url = '';
469 self::$queue_source = 'none';
470
471 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
472 if ( '' === $sitemap ) {
473 $sitemap = home_url( '/wp-sitemap.xml' );
474 }
475
476 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
477 $from = empty( $urls ) ? 'none' : 'sitemap';
478
479 /*
480 * A missing sitemap must not disable the feature. Two very common
481 * setups produce one with no misconfiguration by the user:
482 * `blog_public = 0` (WordPress disables /wp-sitemap.xml outright,
483 * standard on staging and pre-launch sites), and an SEO plugin
484 * filtering `wp_sitemaps_enabled` to false while serving its own
485 * sitemap at a path we were never told about.
486 *
487 * Enumerate warmable URLs straight from the database instead. Only
488 * on a genuine fetch FAILURE — a sitemap that is reachable and
489 * legitimately empty is a real answer, and silently crawling
490 * something else would be worse than doing nothing. (#142)
491 */
492 if ( empty( $urls ) && '' !== self::$last_sitemap_error ) {
493 $urls = self::fallback_urls();
494 $from = empty( $urls ) ? 'none' : 'fallback';
495 }
496
497 $cache_opts = Settings_Manager::get( 'cache' );
498 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
499 if ( ! empty( $excluded ) ) {
500 $urls = array_filter(
501 $urls,
502 static function ( $u ) use ( $excluded ) {
503 $path = (string) wp_parse_url( $u, PHP_URL_PATH );
504 foreach ( $excluded as $needle ) {
505 if ( '' !== $needle && false !== strpos( $path, (string) $needle ) ) {
506 return false;
507 }
508 }
509 return true;
510 }
511 );
512 }
513
514 // Dedup + cap at 5000 to bound the transient size on huge sites.
515 $urls = array_values( array_unique( $urls ) );
516 $urls = array_slice( $urls, 0, 5000 );
517
518 /*
519 * Commit the verdict only now, AFTER the exclusion filter — the
520 * source describes what we ACTUALLY queued, not what we hoped to.
521 * Setting it earlier let a queue that the exclusions stripped to
522 * nothing still claim `fallback`, so the panel announced "Warmed
523 * from site content" over 0 URLs, and `crawlFailed` (which needs
524 * source !== 'fallback' at total 0) could never become true.
525 * One assignment fixes both. (QA F2/F3 on #155)
526 */
527 self::$queue_source = empty( $urls ) ? 'none' : $from;
528
529 return $urls;
530 }
531
532 /**
533 * Enumerate warmable URLs from the database, for sites whose sitemap
534 * can't be fetched. Deliberately modest in scope: the home page, then
535 * the most recently modified public posts across every public post type.
536 * Newest-first is the right bias — those are the URLs most likely to be
537 * requested and least likely to be warm already.
538 *
539 * Uses WP_Query rather than SQL so post-type registration, status
540 * handling and multisite switching all behave the way the rest of
541 * WordPress does.
542 *
543 * @return string[]
544 */
545 private static function fallback_urls(): array {
546 $urls = array();
547 $home = (string) home_url( '/' );
548 if ( '' !== trim( $home, '/' ) ) {
549 $urls[] = $home;
550 }
551
552 $types = get_post_types(
553 array(
554 'public' => true,
555 'publicly_queryable' => true,
556 )
557 );
558 // `page` is public but not publicly_queryable, so the query above
559 // misses it — and pages are exactly what a warm cache wants most.
560 // Only add it when the site has post types at all: an empty list
561 // means there is nothing to enumerate, and constructing a WP_Query
562 // for it would be wasted work.
563 if ( ! empty( $types ) ) {
564 $types['page'] = 'page';
565 unset( $types['attachment'] );
566 }
567
568 if ( empty( $types ) || ! class_exists( '\WP_Query' ) ) {
569 return $urls;
570 }
571
572 $query = new \WP_Query(
573 array(
574 'post_type' => array_values( $types ),
575 'post_status' => 'publish',
576 'posts_per_page' => self::FALLBACK_LIMIT,
577 'orderby' => 'modified',
578 'order' => 'DESC',
579 'ignore_sticky_posts' => true,
580 'no_found_rows' => true,
581 'update_post_meta_cache' => false,
582 'update_post_term_cache' => false,
583 'fields' => 'ids',
584 )
585 );
586
587 foreach ( $query->posts as $post_id ) {
588 $permalink = get_permalink( (int) $post_id );
589 if ( is_string( $permalink ) && '' !== $permalink ) {
590 $urls[] = $permalink;
591 }
592 }
593
594 return array_values( array_unique( $urls ) );
595 }
596
597 /**
598 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
599 * deep index can't stack-overflow.
600 */
601 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
602 if ( $depth > 3 ) {
603 return array();
604 }
605 $res = wp_remote_get(
606 $sitemap_url,
607 array(
608 'timeout' => self::REQUEST_TIMEOUT,
609 'sslverify' => false,
610 'user-agent' => self::USER_AGENT,
611 )
612 );
613 if ( is_wp_error( $res ) ) {
614 // Record WHY, don't just vanish. "Unreachable" and "valid but
615 // empty" both used to collapse into an empty array here, which is
616 // what made a sitemap-less site look like a successful crawl of
617 // zero URLs. Only the top-level fetch is recorded: a nested index
618 // failing is a partial result, not a dead crawl. (#142)
619 if ( 0 === $depth ) {
620 self::$last_sitemap_error = sprintf(
621 /* translators: 1: sitemap URL, 2: error detail. */
622 __( 'Could not fetch the sitemap at %1$s — %2$s', 'xspeed' ),
623 $sitemap_url,
624 $res->get_error_message()
625 );
626 self::$last_sitemap_url = (string) $sitemap_url;
627 }
628 return array();
629 }
630 $code = (int) wp_remote_retrieve_response_code( $res );
631 if ( $code >= 400 ) {
632 if ( 0 === $depth ) {
633 self::$last_sitemap_error = sprintf(
634 /* translators: 1: sitemap URL, 2: HTTP status code. */
635 __( 'Could not fetch the sitemap at %1$s — the server returned HTTP %2$d.', 'xspeed' ),
636 $sitemap_url,
637 $code
638 );
639 self::$last_sitemap_url = (string) $sitemap_url;
640 }
641 return array();
642 }
643 $body = (string) wp_remote_retrieve_body( $res );
644 if ( '' === $body ) {
645 return array();
646 }
647
648 $urls = array();
649 // Sitemap index → recurse.
650 if ( false !== strpos( $body, '<sitemapindex' ) ) {
651 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
652 foreach ( $matches[1] as $child ) {
653 $urls = array_merge( $urls, self::fetch_sitemap_urls( trim( $child ), $depth + 1 ) );
654 }
655 }
656 return $urls;
657 }
658 // URL set → collect.
659 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
660 foreach ( $matches[1] as $u ) {
661 $u = trim( $u );
662 if ( '' !== $u && false !== filter_var( $u, FILTER_VALIDATE_URL ) ) {
663 $urls[] = $u;
664 }
665 }
666 }
667 return $urls;
668 }
669
670 /**
671 * Apply the user's schedule choice. Called on settings change.
672 * Manual = no cron schedule (user must hit "Start now" to crawl).
673 */
674 public static function apply_schedule( string $schedule ): void {
675 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
676 if ( in_array( $schedule, array( 'hourly', 'daily', 'weekly' ), true ) ) {
677 if ( ! wp_next_scheduled( 'xspeed_preloader_recurring' ) ) {
678 wp_schedule_event( time() + 60, $schedule, 'xspeed_preloader_recurring' );
679 }
680 }
681 }
682
683 /**
684 * Recurring schedule hook handler — fires per the user's chosen
685 * cadence and kicks off a fresh crawl unless one is already running.
686 */
687 public static function recurring_kickoff(): void {
688 $state = self::status();
689 if ( $state['running'] ) {
690 return;
691 }
692 self::start();
693 }
694 }
695