Add viator-collector plugin (Hop On Hop Off front-end reskin)
Consolidate the plugin into the TourWebsite repository (its separate nested git repo has been removed). Includes the full new-design reskin: scoped design stylesheet, SVG icon sprite, rebuilt listing/single/section templates, and homepage hero/FAQ/testimonials shortcodes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J4hNL1PSHxXbu94SCenpKj
This commit is contained in:
344
viator-collector/includes/class-vc-cron.php
Normal file
344
viator-collector/includes/class-vc-cron.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Drives the catalogue sync.
|
||||
*
|
||||
* Basic access has no product "modified-since" feed, so the catalogue is
|
||||
* enumerated by crawling /products/search one destination at a time. Each
|
||||
* daily run rebuilds the destination queue and re-crawls everything, updating
|
||||
* existing posts in place (products are deduplicated by code within a run).
|
||||
*
|
||||
* Because the catalogue is large, a run is processed across many short ticks:
|
||||
* each tick crawls a few search pages, saves its position in the queue, and
|
||||
* (if work remains) schedules an immediate follow-up tick. Requests are
|
||||
* throttled by VC_API to stay under the per-minute budget.
|
||||
*/
|
||||
class VC_Cron {
|
||||
const HOOK = 'vc_cron_sync'; // Recurring daily trigger.
|
||||
const CONTINUE = 'vc_cron_continue'; // Single follow-up tick within a run.
|
||||
const LOCK = 'vc_sync_lock';
|
||||
const PAGE_SIZE = 50; // Search maximum per page.
|
||||
const MAX_RUN_ERRORS = 6;
|
||||
|
||||
public static function init() {
|
||||
add_action( self::HOOK, array( __CLASS__, 'run' ) );
|
||||
add_action( self::CONTINUE, array( __CLASS__, 'run' ) );
|
||||
add_action( 'update_option_vc_settings', array( __CLASS__, 'settings_updated' ), 10, 2 );
|
||||
}
|
||||
|
||||
public static function settings_updated( $old, $new ) {
|
||||
if ( ! empty( $new['automatic_sync'] ) ) {
|
||||
self::maybe_schedule();
|
||||
} else {
|
||||
self::unschedule();
|
||||
}
|
||||
}
|
||||
|
||||
public static function maybe_schedule() {
|
||||
if ( ! vc_get_option( 'automatic_sync', false ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! wp_next_scheduled( self::HOOK ) ) {
|
||||
wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
public static function unschedule() {
|
||||
foreach ( array( self::HOOK, self::CONTINUE ) as $hook ) {
|
||||
$timestamp = wp_next_scheduled( $hook );
|
||||
while ( $timestamp ) {
|
||||
wp_unschedule_event( $timestamp, $hook );
|
||||
$timestamp = wp_next_scheduled( $hook );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort any in-progress run and clear progress so the next run restarts.
|
||||
*/
|
||||
public static function reset() {
|
||||
delete_option( 'vc_ingest_in_progress' );
|
||||
delete_option( 'vc_crawl_run_id' );
|
||||
delete_option( 'vc_crawl_queue' );
|
||||
delete_option( 'vc_crawl_start' );
|
||||
delete_option( 'vc_ingest_totals' );
|
||||
delete_option( 'vc_ingest_error_count' );
|
||||
delete_option( 'vc_log_last_count' );
|
||||
delete_transient( self::LOCK );
|
||||
VC_Logger::info( 'Ingestion progress reset.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one tick of the current sync run, starting a new run if idle.
|
||||
*/
|
||||
public static function run() {
|
||||
if ( get_transient( self::LOCK ) ) {
|
||||
VC_Logger::log( 'Sync tick skipped: another tick holds the lock.' );
|
||||
return;
|
||||
}
|
||||
set_transient( self::LOCK, 1, 3 * MINUTE_IN_SECONDS );
|
||||
|
||||
// Keep working even if the triggering HTTP request goes away.
|
||||
if ( function_exists( 'ignore_user_abort' ) ) {
|
||||
ignore_user_abort( true );
|
||||
}
|
||||
|
||||
try {
|
||||
$importer = new VC_Importer();
|
||||
|
||||
if ( ! get_option( 'vc_ingest_in_progress' ) ) {
|
||||
if ( is_wp_error( self::start_run( $importer ) ) ) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$importer->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' );
|
||||
|
||||
// A destination allowlist (comma-separated IDs) narrows the crawl to a
|
||||
// focused set; empty means the whole catalogue.
|
||||
$allow = array_filter( array_map( 'trim', explode( ',', (string) vc_get_option( 'crawl_destinations', '' ) ) ) );
|
||||
if ( ! 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 );
|
||||
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 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user