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

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

675 lines 21.7 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 if ( ! preg_match_all( '#<img\b[^>]*\bsrc\s*=\s*["\']([^"\']+)["\'][^>]*>#i', $html, $m, PREG_SET_ORDER ) ) {
381 return;
382 }
383
384 $home = wp_parse_url( home_url(), PHP_URL_HOST );
385 $targets = array();
386 foreach ( $m as $tag ) {
387 // Only tags MISSING a dimension are worth resolving — one that
388 // already declares both needs nothing.
389 if ( preg_match( '#\bwidth\s*=#i', $tag[0] ) && preg_match( '#\bheight\s*=#i', $tag[0] ) ) {
390 continue;
391 }
392 $src = $tag[1];
393 if ( ! preg_match( '#^https?://#i', $src ) ) {
394 continue;
395 }
396 $host = wp_parse_url( $src, PHP_URL_HOST );
397 if ( ! $host || $host === $home ) {
398 continue; // Local images already resolve from disk.
399 }
400 // Already resolved (or already known unresolvable) — looking it up
401 // again costs a request and teaches us nothing. Skipping it is
402 // also what makes the cap below advance: images appear in the same
403 // DOM order every crawl, so a collector that did not skip would
404 // re-pick the same first N for ever and never reach the rest.
405 if ( Lazy_Loader::dimensions_known( $src ) ) {
406 continue;
407 }
408 $targets[ $src ] = true;
409 if ( count( $targets ) >= self::remote_dimension_limit() ) {
410 break;
411 }
412 }
413
414 if ( $targets ) {
415 Lazy_Loader::warm_dimensions( array_keys( $targets ) );
416 }
417 }
418
419 private static function mark_complete( array $state ): void {
420 $state['running'] = false;
421 $state['finished_at'] = time();
422 $state['queue'] = array();
423 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
424
425 Activity_Log::record(
426 'preloader_completed',
427 sprintf(
428 'Preloader finished — %d/%d URLs warmed, %d error%s.',
429 $state['processed'],
430 $state['total'],
431 count( $state['errors'] ),
432 1 === count( $state['errors'] ) ? '' : 's'
433 ),
434 empty( $state['errors'] ) ? Activity_Log::SUCCESS : Activity_Log::WARN
435 );
436 }
437
438 /**
439 * Build the URL queue for a fresh crawl: parse the sitemap, follow
440 * nested indexes, drop excluded paths.
441 *
442 * @return string[]
443 */
444 private static function resolve_queue( array $opts ): array {
445 // Request-scoped statics: reset so a previous crawl in the same
446 // process can't leak its verdict into this one.
447 self::$last_sitemap_error = '';
448 self::$last_sitemap_url = '';
449 self::$queue_source = 'none';
450
451 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
452 if ( '' === $sitemap ) {
453 $sitemap = home_url( '/wp-sitemap.xml' );
454 }
455
456 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
457 $from = empty( $urls ) ? 'none' : 'sitemap';
458
459 /*
460 * A missing sitemap must not disable the feature. Two very common
461 * setups produce one with no misconfiguration by the user:
462 * `blog_public = 0` (WordPress disables /wp-sitemap.xml outright,
463 * standard on staging and pre-launch sites), and an SEO plugin
464 * filtering `wp_sitemaps_enabled` to false while serving its own
465 * sitemap at a path we were never told about.
466 *
467 * Enumerate warmable URLs straight from the database instead. Only
468 * on a genuine fetch FAILURE — a sitemap that is reachable and
469 * legitimately empty is a real answer, and silently crawling
470 * something else would be worse than doing nothing. (#142)
471 */
472 if ( empty( $urls ) && '' !== self::$last_sitemap_error ) {
473 $urls = self::fallback_urls();
474 $from = empty( $urls ) ? 'none' : 'fallback';
475 }
476
477 $cache_opts = Settings_Manager::get( 'cache' );
478 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
479 if ( ! empty( $excluded ) ) {
480 $urls = array_filter(
481 $urls,
482 static function ( $u ) use ( $excluded ) {
483 $path = (string) wp_parse_url( $u, PHP_URL_PATH );
484 foreach ( $excluded as $needle ) {
485 if ( '' !== $needle && false !== strpos( $path, (string) $needle ) ) {
486 return false;
487 }
488 }
489 return true;
490 }
491 );
492 }
493
494 // Dedup + cap at 5000 to bound the transient size on huge sites.
495 $urls = array_values( array_unique( $urls ) );
496 $urls = array_slice( $urls, 0, 5000 );
497
498 /*
499 * Commit the verdict only now, AFTER the exclusion filter — the
500 * source describes what we ACTUALLY queued, not what we hoped to.
501 * Setting it earlier let a queue that the exclusions stripped to
502 * nothing still claim `fallback`, so the panel announced "Warmed
503 * from site content" over 0 URLs, and `crawlFailed` (which needs
504 * source !== 'fallback' at total 0) could never become true.
505 * One assignment fixes both. (QA F2/F3 on #155)
506 */
507 self::$queue_source = empty( $urls ) ? 'none' : $from;
508
509 return $urls;
510 }
511
512 /**
513 * Enumerate warmable URLs from the database, for sites whose sitemap
514 * can't be fetched. Deliberately modest in scope: the home page, then
515 * the most recently modified public posts across every public post type.
516 * Newest-first is the right bias — those are the URLs most likely to be
517 * requested and least likely to be warm already.
518 *
519 * Uses WP_Query rather than SQL so post-type registration, status
520 * handling and multisite switching all behave the way the rest of
521 * WordPress does.
522 *
523 * @return string[]
524 */
525 private static function fallback_urls(): array {
526 $urls = array();
527 $home = (string) home_url( '/' );
528 if ( '' !== trim( $home, '/' ) ) {
529 $urls[] = $home;
530 }
531
532 $types = get_post_types(
533 array(
534 'public' => true,
535 'publicly_queryable' => true,
536 )
537 );
538 // `page` is public but not publicly_queryable, so the query above
539 // misses it — and pages are exactly what a warm cache wants most.
540 // Only add it when the site has post types at all: an empty list
541 // means there is nothing to enumerate, and constructing a WP_Query
542 // for it would be wasted work.
543 if ( ! empty( $types ) ) {
544 $types['page'] = 'page';
545 unset( $types['attachment'] );
546 }
547
548 if ( empty( $types ) || ! class_exists( '\WP_Query' ) ) {
549 return $urls;
550 }
551
552 $query = new \WP_Query(
553 array(
554 'post_type' => array_values( $types ),
555 'post_status' => 'publish',
556 'posts_per_page' => self::FALLBACK_LIMIT,
557 'orderby' => 'modified',
558 'order' => 'DESC',
559 'ignore_sticky_posts' => true,
560 'no_found_rows' => true,
561 'update_post_meta_cache' => false,
562 'update_post_term_cache' => false,
563 'fields' => 'ids',
564 )
565 );
566
567 foreach ( $query->posts as $post_id ) {
568 $permalink = get_permalink( (int) $post_id );
569 if ( is_string( $permalink ) && '' !== $permalink ) {
570 $urls[] = $permalink;
571 }
572 }
573
574 return array_values( array_unique( $urls ) );
575 }
576
577 /**
578 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
579 * deep index can't stack-overflow.
580 */
581 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
582 if ( $depth > 3 ) {
583 return array();
584 }
585 $res = wp_remote_get(
586 $sitemap_url,
587 array(
588 'timeout' => self::REQUEST_TIMEOUT,
589 'sslverify' => false,
590 'user-agent' => self::USER_AGENT,
591 )
592 );
593 if ( is_wp_error( $res ) ) {
594 // Record WHY, don't just vanish. "Unreachable" and "valid but
595 // empty" both used to collapse into an empty array here, which is
596 // what made a sitemap-less site look like a successful crawl of
597 // zero URLs. Only the top-level fetch is recorded: a nested index
598 // failing is a partial result, not a dead crawl. (#142)
599 if ( 0 === $depth ) {
600 self::$last_sitemap_error = sprintf(
601 /* translators: 1: sitemap URL, 2: error detail. */
602 __( 'Could not fetch the sitemap at %1$s — %2$s', 'xspeed' ),
603 $sitemap_url,
604 $res->get_error_message()
605 );
606 self::$last_sitemap_url = (string) $sitemap_url;
607 }
608 return array();
609 }
610 $code = (int) wp_remote_retrieve_response_code( $res );
611 if ( $code >= 400 ) {
612 if ( 0 === $depth ) {
613 self::$last_sitemap_error = sprintf(
614 /* translators: 1: sitemap URL, 2: HTTP status code. */
615 __( 'Could not fetch the sitemap at %1$s — the server returned HTTP %2$d.', 'xspeed' ),
616 $sitemap_url,
617 $code
618 );
619 self::$last_sitemap_url = (string) $sitemap_url;
620 }
621 return array();
622 }
623 $body = (string) wp_remote_retrieve_body( $res );
624 if ( '' === $body ) {
625 return array();
626 }
627
628 $urls = array();
629 // Sitemap index → recurse.
630 if ( false !== strpos( $body, '<sitemapindex' ) ) {
631 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
632 foreach ( $matches[1] as $child ) {
633 $urls = array_merge( $urls, self::fetch_sitemap_urls( trim( $child ), $depth + 1 ) );
634 }
635 }
636 return $urls;
637 }
638 // URL set → collect.
639 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
640 foreach ( $matches[1] as $u ) {
641 $u = trim( $u );
642 if ( '' !== $u && false !== filter_var( $u, FILTER_VALIDATE_URL ) ) {
643 $urls[] = $u;
644 }
645 }
646 }
647 return $urls;
648 }
649
650 /**
651 * Apply the user's schedule choice. Called on settings change.
652 * Manual = no cron schedule (user must hit "Start now" to crawl).
653 */
654 public static function apply_schedule( string $schedule ): void {
655 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
656 if ( in_array( $schedule, array( 'hourly', 'daily', 'weekly' ), true ) ) {
657 if ( ! wp_next_scheduled( 'xspeed_preloader_recurring' ) ) {
658 wp_schedule_event( time() + 60, $schedule, 'xspeed_preloader_recurring' );
659 }
660 }
661 }
662
663 /**
664 * Recurring schedule hook handler — fires per the user's chosen
665 * cadence and kicks off a fresh crawl unless one is already running.
666 */
667 public static function recurring_kickoff(): void {
668 $state = self::status();
669 if ( $state['running'] ) {
670 return;
671 }
672 self::start();
673 }
674 }
675