'collect', 'last_post_id' => 0, 'cycle_started' => time(), ); self::save_state( $state ); } $deadline = time() + self::time_budget(); if ( 'collect' === $state['phase'] ) { $state = self::run_collect( $state, $deadline ); self::save_state( $state ); } if ( 'check' === $state['phase'] && time() < $deadline ) { $state = self::run_check( $state, $deadline ); self::save_state( $state ); } } /** * Collect phase: extract links from the next slice of posts. * * @param array $state Scan state. * @param int $deadline Unix timestamp to stop at. * @return array Updated state. */ private static function run_collect( $state, $deadline ) { while ( time() < $deadline ) { $posts = self::get_next_posts( (int) $state['last_post_id'], self::POSTS_PER_RUN ); if ( empty( $posts ) ) { $state['phase'] = 'check'; return $state; } foreach ( $posts as $post ) { $links = LGBF_Link_Extractor::extract( $post->post_content, get_permalink( $post ) ); LGBF_Links_Store::sync_post_links( $post->ID, self::filter_links( $links ) ); $state['last_post_id'] = $post->ID; } if ( count( $posts ) < self::POSTS_PER_RUN ) { $state['phase'] = 'check'; return $state; } } return $state; } /** * Applies the "what to check" settings and URL exclusions to an * extracted link set. * * @param array[] $links Items from LGBF_Link_Extractor::extract(). * @return array[] */ private static function filter_links( $links ) { $exclude_patterns = (array) LGBF_Settings::get( 'exclude_urls' ); $check_internal = (bool) LGBF_Settings::get( 'check_internal' ); $check_external = (bool) LGBF_Settings::get( 'check_external' ); $check_images = (bool) LGBF_Settings::get( 'check_images' ); $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); $filtered = array(); foreach ( $links as $link ) { if ( 'image' === $link['type'] && ! $check_images ) { continue; } $is_internal = ( wp_parse_url( $link['url'], PHP_URL_HOST ) === $home_host ); if ( $is_internal && ! $check_internal ) { continue; } if ( ! $is_internal && ! $check_external ) { continue; } if ( LGBF_Link_Extractor::is_excluded( $link['url'], $exclude_patterns ) ) { continue; } $filtered[] = $link; } return $filtered; } /** * Fetches the next batch of posts by ascending ID. * * @param int $after_id Fetch posts with ID greater than this. * @param int $limit Batch size. * @return WP_Post[] */ private static function get_next_posts( $after_id, $limit ) { global $wpdb; $post_types = LGBF_Settings::scannable_post_types(); if ( empty( $post_types ) ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); $sql = $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_status = 'publish' AND post_type IN ({$placeholders}) ORDER BY ID ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge( array( (int) $after_id ), $post_types, array( (int) $limit ) ) ); $ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared return array_values( array_filter( array_map( 'get_post', array_map( 'intval', (array) $ids ) ) ) ); } /** * Check phase: verify up to batch_size pending URLs. * * @param array $state Scan state. * @param int $deadline Unix timestamp to stop at. * @return array Updated state. */ private static function run_check( $state, $deadline ) { $batch_size = (int) LGBF_Settings::get( 'batch_size' ); $pending = LGBF_Links_Store::get_pending_urls( $batch_size ); if ( empty( $pending ) ) { $state['phase'] = 'idle'; update_option( 'lgbf_last_cycle_completed', time(), false ); return $state; } $checked = 0; foreach ( $pending as $row ) { if ( time() >= $deadline ) { break; } $result = LGBF_Checker::check( $row['url'] ); LGBF_Links_Store::apply_check_result( $row['url_hash'], $result ); $checked++; // Breathe between requests so bursts never hammer the server. if ( $checked < count( $pending ) ) { usleep( self::PAUSE_BETWEEN_CHECKS_US ); } } // If the whole batch fit into this run and nothing is left, the next // run flips to idle; leaving the phase as 'check' is always safe. return $state; } /** * Whether a new scan cycle should start. * * @return bool */ public static function cycle_due() { $last = (int) get_option( 'lgbf_last_cycle_completed', 0 ); if ( ! $last ) { return true; } return ( time() - $last ) >= self::frequency_seconds( LGBF_Settings::get( 'scan_frequency' ) ); } /** * Maps a frequency slug to seconds. * * @param string $frequency hourly|twicedaily|daily|weekly. * @return int */ public static function frequency_seconds( $frequency ) { $map = array( 'hourly' => HOUR_IN_SECONDS, 'twicedaily' => 12 * HOUR_IN_SECONDS, 'daily' => DAY_IN_SECONDS, 'weekly' => WEEK_IN_SECONDS, ); return isset( $map[ $frequency ] ) ? $map[ $frequency ] : DAY_IN_SECONDS; } /** * Load-based throttle: true when the 1-minute load average exceeds the * configured threshold (or the CPU count when set to auto). * * @return bool */ public static function is_server_busy() { if ( ! function_exists( 'sys_getloadavg' ) ) { return false; } $load = sys_getloadavg(); if ( ! is_array( $load ) || ! isset( $load[0] ) ) { return false; } return self::load_exceeds_threshold( (float) $load[0], (int) LGBF_Settings::get( 'max_load' ) ); } /** * Pure comparison, unit-testable. * * @param float $load_1m 1-minute load average. * @param int $threshold Configured threshold; 0 = auto (CPU count, min 2). * @return bool */ public static function load_exceeds_threshold( $load_1m, $threshold ) { if ( $threshold <= 0 ) { $threshold = max( 2, self::cpu_count() ); } return $load_1m > (float) $threshold; } /** * Best-effort CPU count. * * @return int */ public static function cpu_count() { static $count = null; if ( null !== $count ) { return $count; } $count = 2; if ( is_readable( '/proc/cpuinfo' ) ) { $cpuinfo = file_get_contents( '/proc/cpuinfo' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $found = preg_match_all( '/^processor\s*:/m', (string) $cpuinfo, $matches ); if ( $found > 0 ) { $count = (int) $found; } } return $count; } /** * Per-run wall clock budget, respecting PHP's max_execution_time. * * @return int Seconds. */ public static function time_budget() { $budget = self::TIME_BUDGET; $max_execution = (int) ini_get( 'max_execution_time' ); if ( $max_execution > 0 ) { $budget = min( $budget, max( 5, $max_execution - 5 ) ); } /** * Filters the scanner's per-run time budget in seconds. * * @param int $budget Seconds. */ return (int) apply_filters( 'lgbf_scan_time_budget', $budget ); } /** * Re-collects links when a post is saved so new URLs enter the queue quickly. * * @param int $post_id Post ID. * @param WP_Post $post Post object. */ public static function on_save_post( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( 'publish' !== $post->post_status ) { return; } if ( ! in_array( $post->post_type, LGBF_Settings::scannable_post_types(), true ) ) { return; } $links = LGBF_Link_Extractor::extract( $post->post_content, get_permalink( $post ) ); LGBF_Links_Store::sync_post_links( $post_id, self::filter_links( $links ) ); } /** * Cleans up when a post is deleted. * * @param int $post_id Post ID. */ public static function on_deleted_post( $post_id ) { LGBF_Links_Store::delete_post_links( $post_id ); } /** * @return array Scan state with defaults. */ public static function get_state() { $state = get_option( self::STATE_OPTION, array() ); if ( ! is_array( $state ) ) { $state = array(); } return wp_parse_args( $state, array( 'phase' => 'idle', 'last_post_id' => 0, 'cycle_started' => 0, ) ); } /** * Persists scan state. * * @param array $state State. */ public static function save_state( $state ) { update_option( self::STATE_OPTION, $state, false ); } /** * Starts a fresh cycle immediately (used by the "Scan now" button). */ public static function request_immediate_scan() { update_option( 'lgbf_last_cycle_completed', 0, false ); self::save_state( array( 'phase' => 'idle', 'last_post_id' => 0, 'cycle_started' => 0, ) ); if ( ! wp_next_scheduled( 'lgbf_scan_batch' ) ) { LGBF_Cron::schedule_events(); } wp_schedule_single_event( time() + 5, 'lgbf_scan_batch' ); } }