PluginProbe
ezCache / 2.6.4
ezCache v2.6.4
2.6.5 2.6.4 2.6.2 2.6.3 2.6.1 2.6.0 2.5.6 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5 2.2.1 2.2.2 trunk 1.2 1.2.1 1.2.2 1.2.3 1.2.4 1.3 1.3.1 1.3.10 1.3.11 All 49 releases
ezcache / includes / Preload.php

Preload.php in ezCache 2.6.4, at includes/Preload.php

566 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Upress\EzCache;
4
5 use Upress\EzCache\Utilities\Logger;
6
7 /**
8 * Cache Preloader.
9 *
10 * Inspired by WP Rocket's Preload module, ezCache's Preload feature warms up
11 * the cache for all the public URLs of the website by crawling them in
12 * background batches. Sources of URLs include:
13 * - The homepage and links found on it
14 * - The XML sitemap (auto-detected or user provided)
15 * - URLs added programmatically via the `ezcache_preload_queue_url` filter
16 *
17 * The queue is stored as a WordPress option to avoid the need for a database
18 * table. URLs are processed in small batches by a recurring cron event so we
19 * don't overload the host. Each URL is fetched twice (desktop + mobile UA) so
20 * both cache variants are generated.
21 */
22 class Preload {
23
24 const QUEUE_OPTION = 'ezcache_preload_queue';
25 const STATE_OPTION = 'ezcache_preload_state';
26 const CRON_HOOK = 'ezcache_preload_process_queue';
27 const CRON_INTERVAL = 'every_minute_ezcache';
28 const PROCESSED_OPTION = 'ezcache_preload_processed';
29
30 /** @var Preload */
31 protected static $instance;
32
33 /** @var Cache */
34 protected $cache;
35
36 /** @var object */
37 protected $settings;
38
39 public static function instance() {
40 if ( ! self::$instance ) {
41 self::$instance = new self();
42 }
43
44 return self::$instance;
45 }
46
47 public function __construct() {
48 $this->cache = Cache::instance();
49 $this->settings = Settings::get_settings();
50
51 add_filter( 'cron_schedules', [ $this, 'register_cron_schedule' ] );
52 add_action( self::CRON_HOOK, [ $this, 'process_queue' ] );
53
54 // Trigger preload after cache is cleared (full or partial).
55 add_action( 'ezcache_after_clear_cache', [ $this, 'maybe_start_preload' ], 10, 0 );
56 add_action( 'ezcache_after_clear_cache_single', [ $this, 'enqueue_post_url' ], 10, 1 );
57 }
58
59 /**
60 * Register a 1-minute cron schedule that we use to process the queue
61 *
62 * @param array $schedules
63 * @return array
64 */
65 public function register_cron_schedule( $schedules ) {
66 if ( ! isset( $schedules[ self::CRON_INTERVAL ] ) ) {
67 $schedules[ self::CRON_INTERVAL ] = [
68 'interval' => 60,
69 'display' => __( 'Every Minute (ezCache Preload)', 'ezcache' ),
70 ];
71 }
72
73 return $schedules;
74 }
75
76 /**
77 * Schedule (or unschedule) the recurring queue processor based on settings.
78 */
79 public function maybe_schedule_cron() {
80 if ( ! empty( $this->settings->enable_preload ) ) {
81 if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
82 wp_schedule_event( time() + 30, self::CRON_INTERVAL, self::CRON_HOOK );
83 }
84 } else {
85 $timestamp = wp_next_scheduled( self::CRON_HOOK );
86 if ( $timestamp ) {
87 wp_unschedule_event( $timestamp, self::CRON_HOOK );
88 }
89 }
90 }
91
92 /**
93 * Start a preload run if the feature is enabled.
94 */
95 public function maybe_start_preload() {
96 if ( empty( $this->settings->enable_preload ) ) {
97 return;
98 }
99
100 if ( empty( $this->settings->preload_on_cache_clear ) ) {
101 return;
102 }
103
104 $this->start();
105 }
106
107 /**
108 * Build the initial queue and kick off the cron processor.
109 *
110 * @return array Status data.
111 */
112 public function start() {
113 $urls = $this->collect_initial_urls();
114 $urls = apply_filters( 'ezcache_preload_initial_urls', $urls );
115
116 $urls = $this->normalize_urls( $urls );
117
118 update_option( self::QUEUE_OPTION, array_values( $urls ), false );
119 update_option( self::PROCESSED_OPTION, [], false );
120 update_option( self::STATE_OPTION, [
121 'status' => 'running',
122 'total' => count( $urls ),
123 'processed' => 0,
124 'started' => time(),
125 'finished' => 0,
126 ], false );
127
128 $this->maybe_schedule_cron();
129
130 // Run an immediate first batch so the user sees movement quickly.
131 if ( ! defined( 'EZCACHE_PRELOAD_NO_IMMEDIATE' ) || ! EZCACHE_PRELOAD_NO_IMMEDIATE ) {
132 $this->process_queue();
133 }
134
135 return $this->get_status();
136 }
137
138 /**
139 * Cancel any in-progress preload.
140 */
141 public function stop() {
142 delete_option( self::QUEUE_OPTION );
143 delete_option( self::PROCESSED_OPTION );
144 update_option( self::STATE_OPTION, [
145 'status' => 'cancelled',
146 'total' => 0,
147 'processed' => 0,
148 'started' => 0,
149 'finished' => time(),
150 ], false );
151
152 $timestamp = wp_next_scheduled( self::CRON_HOOK );
153 if ( $timestamp ) {
154 wp_unschedule_event( $timestamp, self::CRON_HOOK );
155 }
156
157 return $this->get_status();
158 }
159
160 /**
161 * Return the status of the running/last preload run.
162 *
163 * @return array
164 */
165 public function get_status() {
166 $state = get_option( self::STATE_OPTION, [] );
167 $queue = get_option( self::QUEUE_OPTION, [] );
168
169 $defaults = [
170 'status' => 'idle',
171 'total' => 0,
172 'processed' => 0,
173 'started' => 0,
174 'finished' => 0,
175 ];
176 $state = array_merge( $defaults, is_array( $state ) ? $state : [] );
177
178 $state['remaining'] = is_array( $queue ) ? count( $queue ) : 0;
179 $state['enabled'] = ! empty( $this->settings->enable_preload );
180
181 return $state;
182 }
183
184 /**
185 * Add a URL to the queue if it's not yet been processed.
186 *
187 * @param string $url
188 */
189 public function enqueue_url( $url ) {
190 $url = $this->normalize_url( $url );
191 if ( ! $url ) {
192 return;
193 }
194
195 $queue = get_option( self::QUEUE_OPTION, [] );
196 if ( ! is_array( $queue ) ) {
197 $queue = [];
198 }
199 $processed = get_option( self::PROCESSED_OPTION, [] );
200 if ( ! is_array( $processed ) ) {
201 $processed = [];
202 }
203
204 if ( in_array( $url, $queue, true ) || in_array( $url, $processed, true ) ) {
205 return;
206 }
207
208 $queue[] = $url;
209 update_option( self::QUEUE_OPTION, array_values( $queue ), false );
210
211 $state = get_option( self::STATE_OPTION, [] );
212 if ( ! is_array( $state ) ) {
213 $state = [];
214 }
215 $state['total'] = ( isset( $state['total'] ) ? (int) $state['total'] : 0 ) + 1;
216 $state['status'] = 'running';
217 update_option( self::STATE_OPTION, $state, false );
218
219 $this->maybe_schedule_cron();
220 }
221
222 /**
223 * Convenience method to enqueue a single post permalink.
224 *
225 * @param int $post_id
226 */
227 public function enqueue_post_url( $post_id ) {
228 if ( empty( $this->settings->enable_preload ) ) {
229 return;
230 }
231
232 $url = get_permalink( $post_id );
233 if ( $url ) {
234 $this->enqueue_url( $url );
235 }
236 }
237
238 /**
239 * Process the next batch of URLs from the queue.
240 */
241 public function process_queue() {
242 $queue = get_option( self::QUEUE_OPTION, [] );
243 if ( ! is_array( $queue ) || empty( $queue ) ) {
244 $state = get_option( self::STATE_OPTION, [] );
245 if ( is_array( $state ) && isset( $state['status'] ) && 'running' === $state['status'] ) {
246 $state['status'] = 'completed';
247 $state['finished'] = time();
248 update_option( self::STATE_OPTION, $state, false );
249 }
250 return;
251 }
252
253 $batch_size = isset( $this->settings->preload_batch_size ) ? max( 1, (int) $this->settings->preload_batch_size ) : 5;
254 $batch = array_splice( $queue, 0, $batch_size );
255
256 // Save updated queue first so a long fetch doesn't reprocess the same URL.
257 update_option( self::QUEUE_OPTION, array_values( $queue ), false );
258
259 $processed = get_option( self::PROCESSED_OPTION, [] );
260 if ( ! is_array( $processed ) ) {
261 $processed = [];
262 }
263
264 foreach ( $batch as $url ) {
265 $this->fetch_url( $url );
266 $processed[] = $url;
267 }
268
269 // Cap processed list to prevent it from growing without bound on huge sites.
270 $cap = (int) apply_filters( 'ezcache_preload_processed_cap', 50000 );
271 if ( count( $processed ) > $cap ) {
272 $processed = array_slice( $processed, -1 * $cap );
273 }
274
275 update_option( self::PROCESSED_OPTION, $processed, false );
276
277 $state = get_option( self::STATE_OPTION, [] );
278 if ( ! is_array( $state ) ) {
279 $state = [];
280 }
281 $state['processed'] = ( isset( $state['processed'] ) ? (int) $state['processed'] : 0 ) + count( $batch );
282 $state['status'] = empty( $queue ) ? 'completed' : 'running';
283 if ( 'completed' === $state['status'] ) {
284 $state['finished'] = time();
285 }
286 update_option( self::STATE_OPTION, $state, false );
287 }
288
289 /**
290 * Fetch a single URL with both desktop and mobile user agents to populate
291 * both cache variants.
292 *
293 * @param string $url
294 */
295 protected function fetch_url( $url ) {
296 $desktop_ua = apply_filters(
297 'ezcache_desktop_useragent',
298 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 (ezCache Preload)'
299 );
300 $mobile_ua = apply_filters(
301 'ezcache_mobile_useragent',
302 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 (ezCache Preload)'
303 );
304
305 $args = [
306 'timeout' => 30,
307 'redirection' => 3,
308 'blocking' => true,
309 'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
310 'user-agent' => $desktop_ua,
311 'headers' => [
312 'X-Ezcache-Preload' => '1',
313 'Cache-Control' => 'no-cache',
314 ],
315 ];
316
317 wp_safe_remote_get( $url, $args );
318
319 if ( ! empty( $this->settings->separate_mobile_cache ) ) {
320 $args['user-agent'] = $mobile_ua;
321 wp_safe_remote_get( $url, $args );
322 }
323
324 // Optionally extract internal links from the homepage to seed the queue.
325 if ( $this->is_homepage( $url ) && ! empty( $this->settings->preload_crawl_homepage_links ) ) {
326 $this->seed_from_homepage( $url );
327 }
328
329 Logger::log( 'ezCache Preload fetched ' . $url );
330 }
331
332 /**
333 * Returns true when the URL points to the site homepage.
334 *
335 * @param string $url
336 * @return bool
337 */
338 protected function is_homepage( $url ) {
339 return untrailingslashit( $url ) === untrailingslashit( home_url() );
340 }
341
342 /**
343 * Crawl the homepage HTML and seed the queue with internal links found there.
344 *
345 * @param string $url
346 */
347 protected function seed_from_homepage( $url ) {
348 $response = wp_safe_remote_get( $url, [
349 'timeout' => 30,
350 'sslverify' => false,
351 'user-agent' => 'ezCache Preload Crawler',
352 ] );
353
354 if ( is_wp_error( $response ) ) {
355 return;
356 }
357
358 $body = wp_remote_retrieve_body( $response );
359 if ( empty( $body ) ) {
360 return;
361 }
362
363 if ( ! preg_match_all( '#<a\s[^>]*href=["\']([^"\']+)["\']#i', $body, $matches ) ) {
364 return;
365 }
366
367 $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
368 foreach ( $matches[1] as $href ) {
369 $href = trim( $href );
370 $href = strtok( $href, '#' );
371 $first = substr( $href, 0, 1 );
372
373 if ( '' === $href || '#' === $first || 0 === strpos( $href, 'mailto:' ) || 0 === strpos( $href, 'tel:' ) || 0 === strpos( $href, 'javascript:' ) ) {
374 continue;
375 }
376
377 // Resolve protocol relative & relative URLs.
378 if ( 0 === strpos( $href, '//' ) ) {
379 $href = ( is_ssl() ? 'https:' : 'http:' ) . $href;
380 } elseif ( '/' === $first ) {
381 $href = home_url( $href );
382 } elseif ( ! preg_match( '#^https?://#i', $href ) ) {
383 continue;
384 }
385
386 $host = wp_parse_url( $href, PHP_URL_HOST );
387 if ( $host !== $home_host ) {
388 continue;
389 }
390
391 $this->enqueue_url( $href );
392 }
393 }
394
395 /**
396 * Build the initial list of URLs from sitemaps and the homepage.
397 *
398 * @return array
399 */
400 protected function collect_initial_urls() {
401 $urls = [ home_url( '/' ) ];
402
403 // Try the user provided sitemap first.
404 $sitemap_urls = [];
405 if ( ! empty( $this->settings->preload_sitemap_url ) ) {
406 $sitemap_urls[] = trim( $this->settings->preload_sitemap_url );
407 }
408
409 // Auto-detect common sitemap locations.
410 $sitemap_urls = array_merge( $sitemap_urls, [
411 home_url( '/wp-sitemap.xml' ),
412 home_url( '/sitemap_index.xml' ),
413 home_url( '/sitemap.xml' ),
414 ] );
415
416 $found = false;
417 foreach ( $sitemap_urls as $sitemap_url ) {
418 $found_urls = $this->parse_sitemap( $sitemap_url );
419 if ( ! empty( $found_urls ) ) {
420 $urls = array_merge( $urls, $found_urls );
421 $found = true;
422 break;
423 }
424 }
425
426 if ( ! $found ) {
427 // Fallback: use recently published posts and pages.
428 $query = new \WP_Query( [
429 'post_type' => [ 'post', 'page' ],
430 'post_status' => 'publish',
431 'posts_per_page' => 200,
432 'orderby' => 'modified',
433 'order' => 'DESC',
434 'no_found_rows' => true,
435 'fields' => 'ids',
436 ] );
437
438 foreach ( $query->posts as $post_id ) {
439 $urls[] = get_permalink( $post_id );
440 }
441 }
442
443 return $urls;
444 }
445
446 /**
447 * Parse an XML sitemap (recursively) and return all `<loc>` URLs.
448 *
449 * @param string $sitemap_url
450 * @param int $depth
451 * @return array
452 */
453 protected function parse_sitemap( $sitemap_url, $depth = 0 ) {
454 if ( $depth > 3 ) {
455 return [];
456 }
457
458 $response = wp_safe_remote_get( $sitemap_url, [
459 'timeout' => 30,
460 'sslverify' => false,
461 'user-agent' => 'ezCache Preload',
462 ] );
463
464 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
465 return [];
466 }
467
468 $body = wp_remote_retrieve_body( $response );
469 if ( empty( $body ) ) {
470 return [];
471 }
472
473 libxml_use_internal_errors( true );
474 $xml = simplexml_load_string( $body );
475 if ( false === $xml ) {
476 return [];
477 }
478
479 $urls = [];
480
481 // Sitemap index: contains <sitemap><loc>...</loc></sitemap> entries.
482 if ( isset( $xml->sitemap ) ) {
483 foreach ( $xml->sitemap as $entry ) {
484 $loc = (string) $entry->loc;
485 if ( $loc ) {
486 $urls = array_merge( $urls, $this->parse_sitemap( $loc, $depth + 1 ) );
487 }
488 }
489 }
490
491 // Regular sitemap: contains <url><loc>...</loc></url> entries.
492 if ( isset( $xml->url ) ) {
493 foreach ( $xml->url as $entry ) {
494 $loc = (string) $entry->loc;
495 if ( $loc ) {
496 $urls[] = $loc;
497 }
498 }
499 }
500
501 return $urls;
502 }
503
504 /**
505 * Normalize a URL: trim, ensure same host, drop fragments, dedupe.
506 *
507 * @param string $url
508 * @return string|false
509 */
510 protected function normalize_url( $url ) {
511 $url = trim( (string) $url );
512 if ( empty( $url ) ) {
513 return false;
514 }
515
516 $url = strtok( $url, '#' );
517
518 if ( ! preg_match( '#^https?://#i', $url ) ) {
519 return false;
520 }
521
522 $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
523 $url_host = wp_parse_url( $url, PHP_URL_HOST );
524 if ( ! $url_host || $url_host !== $home_host ) {
525 return false;
526 }
527
528 // Excluded URIs from settings: don't waste cycles preloading them.
529 $settings = $this->settings;
530 if ( ! empty( $settings->rejected_uri ) ) {
531 $rejected = preg_split( "/\\r\\n|\\r|\\n/u", trim( $settings->rejected_uri ), -1, PREG_SPLIT_NO_EMPTY );
532 $path = wp_parse_url( $url, PHP_URL_PATH );
533 foreach ( $rejected as $pattern ) {
534 $pattern = trim( $pattern );
535 if ( '' === $pattern ) {
536 continue;
537 }
538 $regex = str_replace( '\*', '.*', preg_quote( $pattern, '#' ) );
539 if ( @preg_match( '#^' . $regex . '/?$#u', $path ) ) {
540 return false;
541 }
542 }
543 }
544
545 return $url;
546 }
547
548 /**
549 * Normalize and dedupe an array of URLs.
550 *
551 * @param array $urls
552 * @return array
553 */
554 protected function normalize_urls( $urls ) {
555 $out = [];
556 foreach ( (array) $urls as $url ) {
557 $url = $this->normalize_url( $url );
558 if ( $url && ! isset( $out[ $url ] ) ) {
559 $out[ $url ] = true;
560 }
561 }
562
563 return array_keys( $out );
564 }
565 }
566