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:
2026-09-12 21:11:20 +03:30
parent 99e5413fee
commit 7ada8860f8
42 changed files with 9019 additions and 0 deletions

View 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 ),
);
}
}