load_dictionaries(); } self::process_tick( $importer, self::deadline() ); } finally { delete_transient( self::LOCK ); // Spawn the next tick only after the lock is free, so the loopback // request does not collide with this one. if ( wp_next_scheduled( self::CONTINUE ) ) { self::spawn(); } } } /** * Wall-clock time by which a tick must stop, so it never hits the PHP * max_execution_time and dies mid-page (which would strand the lock). */ private static function deadline() { $max = (int) ini_get( 'max_execution_time' ); $budget = ( $max > 0 ) ? min( 25, max( 8, $max - 5 ) ) : 25; return microtime( true ) + $budget; } /** * Products requested per search page (Viator caps this at 50). */ private static function page_size() { return max( 1, min( 50, absint( vc_get_option( 'products_per_page', 50 ) ) ) ); } /** * Fire a non-blocking loopback request so WP-Cron processes the queued * continuation tick even when the site has no visitor traffic. */ private static function spawn() { if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { // A real server cron is responsible for running events. return; } $url = site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ); wp_remote_post( $url, array( 'timeout' => 0.01, 'blocking' => false, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); } /** * Initialise a run: refresh dictionaries and build the destination queue. */ private static function start_run( VC_Importer $importer ) { $primed = $importer->prime_dictionaries(); if ( is_wp_error( $primed ) ) { VC_Logger::error( 'Sync aborted, could not load dictionaries: ' . $primed->get_error_message() ); return $primed; } $map = get_transient( 'vc_destination_map' ); // Cities configured on the Viator Structure screen define the crawl. // Without any, a destination allowlist (comma-separated IDs) narrows // the crawl to a focused set; empty means the whole catalogue. $cities = array_map( 'strval', array_keys( VC_Structure::cities( VC_Structure::get() ) ) ); $allow = array_filter( array_map( 'trim', explode( ',', (string) vc_get_option( 'crawl_destinations', '' ) ) ) ); if ( ! empty( $cities ) ) { $queue = $cities; } elseif ( ! empty( $allow ) ) { $queue = array_values( array_unique( $allow ) ); } else { $queue = is_array( $map ) ? array_keys( $map ) : array(); } if ( empty( $queue ) ) { VC_Logger::error( 'Sync aborted: destination list is empty.' ); return new WP_Error( 'vc_no_destinations', 'No destinations to crawl.' ); } update_option( 'vc_crawl_run_id', gmdate( 'Y-m-d\TH:i:s\Z' ) . '-' . wp_rand( 1000, 9999 ), false ); update_option( 'vc_crawl_queue', array_values( $queue ), false ); update_option( 'vc_crawl_start', 1, false ); update_option( 'vc_ingest_error_count', 0, false ); update_option( 'vc_log_last_count', 0, false ); update_option( 'vc_ingest_totals', array( 'destinations_total' => count( $queue ), 'destinations_done' => 0, 'pages' => 0, 'imported' => 0, 'updated' => 0, 'duplicate' => 0, 'deactivated' => 0, 'failed' => 0, ), false ); update_option( 'vc_ingest_in_progress', 1, false ); update_option( 'vc_last_sync_started_at', current_time( 'mysql' ), false ); if ( ! empty( $cities ) ) { VC_Logger::info( sprintf( 'Sync run started from structure — %d cities queued.', count( $queue ) ) ); } else { VC_Logger::info( sprintf( 'Sync run started — %d destinations queued.', count( $queue ) ) ); } return true; } private static function process_tick( VC_Importer $importer, $deadline ) { // With full-detail ON every product costs an extra API call, so each // tick must stay small: one short page per cron minute. Without it, // search-only is cheap and we can sweep many pages per tick. if ( vc_get_option( 'fetch_full_detail', 0 ) ) { $pages_per_run = 1; $page_size = max( 1, min( 25, absint( vc_get_option( 'detail_batch_size', 15 ) ) ) ); } else { $pages_per_run = max( 1, absint( vc_get_option( 'pages_per_run', 10 ) ) ); $page_size = self::page_size(); } $run_id = (string) get_option( 'vc_crawl_run_id', '' ); $totals = get_option( 'vc_ingest_totals', array() ); for ( $i = 0; $i < $pages_per_run; $i++ ) { $queue = get_option( 'vc_crawl_queue', array() ); if ( empty( $queue ) ) { self::finish_run( $totals ); return; } // Stop cleanly before we risk a PHP timeout; resume next tick. if ( $i > 0 && microtime( true ) >= $deadline ) { break; } $destination = (string) $queue[0]; $start = max( 1, (int) get_option( 'vc_crawl_start', 1 ) ); $result = $importer->crawl_destination( $destination, $start, $page_size, $run_id ); if ( is_wp_error( $result ) ) { $errors = (int) get_option( 'vc_ingest_error_count', 0 ) + 1; update_option( 'vc_ingest_error_count', $errors, false ); VC_Logger::error( 'Error on destination ' . $destination . ' (failure ' . $errors . ' of ' . self::MAX_RUN_ERRORS . '): ' . $result->get_error_message() ); if ( $errors >= self::MAX_RUN_ERRORS ) { VC_Logger::error( 'Sync aborted after too many errors. It will retry on the next scheduled run.' ); update_option( 'vc_ingest_in_progress', 0, false ); return; } self::schedule_continue( MINUTE_IN_SECONDS ); return; } update_option( 'vc_ingest_error_count', 0, false ); $totals['pages'] = (int) $totals['pages'] + 1; $totals['imported'] = (int) $totals['imported'] + (int) $result['imported']; $totals['updated'] = (int) $totals['updated'] + (int) $result['updated']; $totals['duplicate'] = (int) $totals['duplicate'] + (int) $result['duplicate']; $totals['deactivated'] = (int) $totals['deactivated'] + (int) $result['deactivated']; $totals['failed'] = (int) $totals['failed'] + (int) $result['failed']; $returned = (int) $result['count']; $total = (int) $result['total']; $consumed = ( $start - 1 ) + $returned; $has_more = $returned >= $page_size && ( $total <= 0 || $consumed < $total ); if ( $has_more ) { update_option( 'vc_crawl_start', $start + $page_size, false ); } else { array_shift( $queue ); update_option( 'vc_crawl_queue', array_values( $queue ), false ); update_option( 'vc_crawl_start', 1, false ); $totals['destinations_done'] = (int) $totals['destinations_done'] + 1; } update_option( 'vc_ingest_totals', $totals, false ); self::maybe_log_progress( $totals ); if ( empty( $queue ) && ! $has_more ) { self::finish_run( $totals ); return; } } self::schedule_continue( 0 ); } /** * Emit an info entry each time the processed-product count crosses the * configured step (default every 10 products). */ private static function maybe_log_progress( array $totals ) { $processed = (int) $totals['imported'] + (int) $totals['updated'] + (int) $totals['deactivated']; $step = max( 1, absint( vc_get_option( 'log_every', 10 ) ) ); $last = (int) get_option( 'vc_log_last_count', 0 ); if ( $processed - $last < $step ) { return; } update_option( 'vc_log_last_count', $processed, false ); VC_Logger::info( sprintf( 'Progress: %d products processed (new %d, updated %d, deactivated %d, failed %d) across %d pages; %d/%d destinations done.', $processed, (int) $totals['imported'], (int) $totals['updated'], (int) $totals['deactivated'], (int) $totals['failed'], (int) $totals['pages'], (int) $totals['destinations_done'], (int) $totals['destinations_total'] ) ); } private static function finish_run( array $totals ) { update_option( 'vc_ingest_in_progress', 0, false ); update_option( 'vc_last_sync_completed_at', current_time( 'mysql' ), false ); update_option( 'vc_last_sync_totals', $totals, false ); delete_option( 'vc_crawl_queue' ); delete_option( 'vc_crawl_start' ); VC_Logger::info( sprintf( 'Sync run complete: %d new, %d updated, %d deactivated, %d failed (%d pages, %d destinations).', (int) $totals['imported'], (int) $totals['updated'], (int) $totals['deactivated'], (int) $totals['failed'], (int) $totals['pages'], (int) $totals['destinations_total'] ) ); } private static function schedule_continue( $delay ) { if ( ! wp_next_scheduled( self::CONTINUE ) ) { // The loopback spawn in run() runs due events, so default to "now". wp_schedule_single_event( time() + max( 0, (int) $delay ), self::CONTINUE ); } } /** * Human-readable status used by the settings screen. */ public static function status() { $queue = get_option( 'vc_crawl_queue', array() ); return array( 'in_progress' => (bool) get_option( 'vc_ingest_in_progress' ), 'queue_remaining' => is_array( $queue ) ? count( $queue ) : 0, 'started_at' => get_option( 'vc_last_sync_started_at', '' ), 'completed_at' => get_option( 'vc_last_sync_completed_at', '' ), 'totals' => get_option( 'vc_ingest_totals', array() ), 'last_totals' => get_option( 'vc_last_sync_totals', array() ), 'next_scheduled' => wp_next_scheduled( self::HOOK ), ); } }