$now ) { continue; } $missing[] = $url; if ( count( $missing ) >= $batch ) { break; } }//end foreach if ( empty( $missing ) ) { self::save_skip_list( $skip ); return 0; } foreach ( $missing as $url ) { $attempts = isset( $skip[ $url ]['n'] ) ? (int) $skip[ $url ]['n'] + 1 : 1; $skip[ $url ] = [ 'n' => $attempts, // Three attempts is enough to distinguish "not visited yet" from // "will never cache"; after that, back off for a day so a page // that becomes cacheable is still picked up eventually. 'until' => $attempts >= 3 ? $now + DAY_IN_SECONDS : 0, ]; } self::save_skip_list( $skip ); return self::queue_warm( $missing, 0 ); } /** * Per-URL crawl attempt record. * * @return array */ private static function skip_list() { $list = get_option( self::SKIP_OPTION, [] ); return is_array( $list ) ? $list : []; } /** * Persist the crawl attempt record, bounded. * * @param array $list Attempt record. */ private static function save_skip_list( $list ) { // Bounded so a site with a very large sitemap cannot grow this option // without limit. Entries still in backoff are kept in preference to ones // that have simply not been reached yet. if ( count( $list ) > 500 ) { uasort( $list, function ( $a, $b ) { return ( isset( $b['until'] ) ? $b['until'] : 0 ) <=> ( isset( $a['until'] ) ? $a['until'] : 0 ); } ); $list = array_slice( $list, 0, 500, true ); } update_option( self::SKIP_OPTION, $list, false ); } /** * Does a cache entry already exist for a URL? * * @param string $url URL to check. * @return bool */ public static function is_cached( $url ) { $parts = wp_parse_url( $url ); if ( empty( $parts['host'] ) ) { return false; } $file = Store::file_path( $parts['host'], isset( $parts['path'] ) ? $parts['path'] : '/', '' ); return $file && file_exists( $file ); } /** * Remove every scheduled job. Called on deactivation. */ public static function clear_events() { wp_clear_scheduled_hook( self::PRUNE_HOOK ); if ( self::has_action_scheduler() ) { as_unschedule_all_actions( self::WARM_HOOK ); } } /** * Expire stale cached pages and reclaim unused stylesheets. * * @return array{pages:int, css:int} */ public static function run_prune() { $pages = Store::prune(); $css_days = (int) apply_filters( 'ablocks/perf/page_cache/css_max_age_days', self::CSS_MAX_AGE_DAYS ); $css = StyleConsolidator::prune( $css_days ); return [ 'pages' => (int) $pages, 'css' => (int) $css, ]; } /** * Queue a set of URLs to be rendered into the cache. * * Requests are spaced rather than fired at once. Warming exists to spare * visitors a render; doing it in one burst would hand the site the exact * load spike the cache is meant to prevent. * * @param string[] $urls URLs to warm. * @param int $delay Seconds before the first request. * @return int Number of jobs queued. */ public static function queue_warm( array $urls, $delay = 10 ) { $urls = array_values( array_unique( array_filter( array_map( 'esc_url_raw', $urls ) ) ) ); if ( empty( $urls ) ) { return 0; } $spacing = max( 1, (int) apply_filters( 'ablocks/perf/page_cache/warm_spacing', self::WARM_SPACING ) ); $queued = 0; foreach ( $urls as $index => $url ) { $when = time() + (int) $delay + ( $index * $spacing ); if ( self::has_action_scheduler() ) { as_schedule_single_action( $when, self::WARM_HOOK, [ $url ], 'ablocks' ); } else { // WP-Cron dedupes on hook + args, so a URL already queued is not // queued twice — which is the behaviour we want anyway. if ( ! wp_next_scheduled( self::WARM_HOOK, [ $url ] ) ) { wp_schedule_single_event( $when, self::WARM_HOOK, [ $url ] ); } } $queued++; } return $queued; } /** * Render one URL so its cache entry exists before a visitor asks for it. * * A plain anonymous GET: the write path decides on its own whether the * response is cacheable, so warming needs no special privileges and cannot * cache something a visitor would not have. * * @param string $url URL to fetch. */ public static function warm_url( $url ) { $url = esc_url_raw( (string) $url ); if ( empty( $url ) ) { return; } // Same-origin only. This runs from a queue whose contents could be // filtered by other code, and it must never become a way to make the // server issue arbitrary outbound requests. $home = wp_parse_url( home_url() ); $want = wp_parse_url( $url ); if ( empty( $want['host'] ) || empty( $home['host'] ) || strtolower( $want['host'] ) !== strtolower( $home['host'] ) ) { return; } wp_remote_get( $url, [ 'timeout' => 15, 'blocking' => false, 'sslverify' => false, 'user-agent' => 'aBlocks-Cache-Warmer/' . ABLOCKS_VERSION, 'headers' => [ 'X-ABlocks-Warm' => '1' ], ] ); } /** * URLs worth warming. * * The site's own sitemap is preferred: it is the site's statement of what * matters, and it includes archives, taxonomy and author pages that a post * query alone would miss — which is what the other preloaders in this space * read too. The post query remains as a fallback for sites that have the * sitemap disabled or replaced. * * @param int $limit Maximum URLs to return. * @return string[] */ public static function warmable_urls( $limit = 100 ) { $limit = max( 1, (int) $limit ); $urls = self::sitemap_urls( $limit ); if ( count( $urls ) > 1 ) { return array_slice( $urls, 0, $limit ); } return self::queried_urls( $limit ); } /** * URLs read from the WordPress sitemap index. * * Fetched over HTTP rather than by calling the sitemap provider directly, * because SEO plugins commonly replace core's sitemap with their own at the * same address; going through the URL gets whichever one the site actually * publishes. * * @param int $limit Stop after this many URLs. * @return string[] */ public static function sitemap_urls( $limit = 100 ) { // Several candidates, because the address depends on what publishes the // sitemap: core uses wp-sitemap.xml, Yoast and RankMath use // sitemap_index.xml, others use sitemap.xml. Probing costs one request // each and only until the first that answers, which beats making the // user configure it. A site with none of them falls back to the query. $candidates = (array) apply_filters( 'ablocks/perf/page_cache/sitemap_urls', [ home_url( '/wp-sitemap.xml' ), home_url( '/sitemap_index.xml' ), home_url( '/sitemap.xml' ), ] ); $children = []; foreach ( $candidates as $candidate ) { $children = self::read_sitemap( $candidate ); if ( ! empty( $children ) ) { break; } } if ( empty( $children ) ) { return []; } // An index lists sub-sitemaps; a flat sitemap lists pages. Both arrive as // values, so tell them apart by whether they look like sitemaps. $sub = array_values( array_filter( $children, function ( $url ) { return (bool) preg_match( '#\.xml(\?|$)#i', $url ); } ) ); if ( empty( $sub ) ) { return array_slice( $children, 0, $limit ); } $urls = [ home_url( '/' ) ]; foreach ( $sub as $child ) { $urls = array_merge( $urls, self::read_sitemap( $child ) ); if ( count( $urls ) >= $limit ) { break; } } return array_values( array_unique( $urls ) ); } /** * Extract values from one sitemap document. * * @param string $url Sitemap URL. * @return string[] */ private static function read_sitemap( $url ) { if ( ! self::same_origin( $url ) ) { return []; } $response = wp_remote_get( $url, [ 'timeout' => 20, 'sslverify' => false, 'user-agent' => 'aBlocks-Cache-Warmer/' . ABLOCKS_VERSION, ] ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return []; } $body = wp_remote_retrieve_body( $response ); if ( empty( $body ) || ! preg_match_all( '#\s*([^<]+?)\s*#i', $body, $matches ) ) { return []; } $urls = []; foreach ( $matches[1] as $loc ) { $loc = esc_url_raw( html_entity_decode( trim( $loc ), ENT_QUOTES, 'UTF-8' ) ); if ( $loc && self::same_origin( $loc ) ) { $urls[] = $loc; } } return array_values( array_unique( $urls ) ); } /** * Is a URL on this site? * * @param string $url Candidate URL. * @return bool */ private static function same_origin( $url ) { $home = wp_parse_url( home_url() ); $want = wp_parse_url( $url ); return ! empty( $want['host'] ) && ! empty( $home['host'] ) && strtolower( $want['host'] ) === strtolower( $home['host'] ); } /** * Fallback URL list: recently modified public posts, plus the front page. * * @param int $limit Maximum URLs to return. * @return string[] */ private static function queried_urls( $limit = 100 ) { $limit = max( 1, (int) $limit ); $post_types = array_values( array_filter( get_post_types( [ 'public' => true ], 'names' ), function ( $type ) { return 'attachment' !== $type; } ) ); $query = new \WP_Query( [ 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'modified', 'order' => 'DESC', 'has_password' => false, 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'fields' => 'ids', ] ); $urls = [ home_url( '/' ) ]; foreach ( $query->posts as $post_id ) { $permalink = get_permalink( $post_id ); if ( $permalink ) { $urls[] = $permalink; } } return array_values( array_unique( $urls ) ); } /** * Is Action Scheduler available to this request? * * Checked by function, not by plugin: Action Scheduler is normally bundled * inside another plugin's vendor directory rather than activated on its own, * and a bundled copy works exactly as well. * * @return bool */ public static function has_action_scheduler() { return function_exists( 'as_schedule_single_action' ) && function_exists( 'as_unschedule_all_actions' ); } /** * Which backend bulk warming will use, for display. * * @return string */ public static function warm_backend() { return self::has_action_scheduler() ? 'action-scheduler' : 'wp-cron'; } }