PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / class-preloader.php

class-preloader.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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