Files
hoponhopofftickets/viator-collector/includes/class-vc-importer.php

520 lines
18 KiB
PHP

<?php
defined( 'ABSPATH' ) || exit;
class VC_Importer {
private $api;
private $destination_map = array();
private $tag_map = array();
public function __construct( VC_API $api = null ) {
$this->api = $api ? $api : new VC_API();
}
/**
* Force-refresh the destination and tag dictionaries and cache them.
* Returns a WP_Error if either lookup fails (so a run can abort early).
*/
public function prime_dictionaries() {
$dest = $this->api->get_destination_map();
if ( is_wp_error( $dest ) ) {
return $dest;
}
$tags = $this->api->get_tag_map();
if ( is_wp_error( $tags ) ) {
return $tags;
}
set_transient( 'vc_destination_map', $dest, 25 * HOUR_IN_SECONDS );
set_transient( 'vc_tag_map', $tags, 25 * HOUR_IN_SECONDS );
$this->destination_map = $dest;
$this->tag_map = $tags;
return true;
}
/**
* Load dictionaries from cache, fetching them if the cache is cold.
*/
public function load_dictionaries() {
$dest = get_transient( 'vc_destination_map' );
if ( false === $dest ) {
$dest = $this->api->get_destination_map();
$dest = is_wp_error( $dest ) ? array() : $dest;
set_transient( 'vc_destination_map', $dest, 25 * HOUR_IN_SECONDS );
}
$tags = get_transient( 'vc_tag_map' );
if ( false === $tags ) {
$tags = $this->api->get_tag_map();
$tags = is_wp_error( $tags ) ? array() : $tags;
set_transient( 'vc_tag_map', $tags, 25 * HOUR_IN_SECONDS );
}
$this->destination_map = is_array( $dest ) ? $dest : array();
$this->tag_map = is_array( $tags ) ? $tags : array();
}
/**
* Crawl one page of products for a single destination.
*
* Each product is deduplicated by code within a run (via $run_id) so its
* full detail is only fetched once even though it surfaces under several
* destinations.
*
* @return array|WP_Error Per-page summary including totalCount and page size.
*/
public function crawl_destination( $destination_id, $start, $count, $run_id ) {
$currency = vc_get_option( 'currency', 'USD' );
$response = $this->api->search_products( $destination_id, $start, $count, $currency );
if ( is_wp_error( $response ) ) {
return $response;
}
$products = vc_array_get( $response, 'products', array() );
$summary = array(
'count' => 0,
'imported' => 0,
'updated' => 0,
'duplicate' => 0,
'deactivated' => 0,
'failed' => 0,
'total' => (int) vc_first_non_empty( array( vc_array_get( $response, 'totalCount' ), vc_array_get( $response, 'total' ) ), 0 ),
);
foreach ( (array) $products as $product ) {
if ( ! is_array( $product ) ) {
continue;
}
$summary['count']++;
$outcome = $this->process_search_product( $product, $destination_id, $run_id );
if ( isset( $summary[ $outcome ] ) ) {
$summary[ $outcome ]++;
}
}
return $summary;
}
/**
* Process one product returned by search: skip if already handled this
* run, otherwise optionally fetch full detail and create/update the post.
*
* @return string One of imported|updated|duplicate|deactivated|failed.
*/
private function process_search_product( array $product, $destination_id, $run_id ) {
$code = (string) vc_first_non_empty( array( vc_array_get( $product, 'productCode' ), vc_array_get( $product, 'code' ) ) );
if ( '' === $code ) {
return 'failed';
}
$existing = $this->find_existing_tour( $code );
if ( $existing && (string) get_post_meta( $existing, '_vc_synced_run', true ) === (string) $run_id ) {
// Already fully processed under another destination during this run.
return 'duplicate';
}
// Optionally enrich the summary with full product detail.
if ( vc_get_option( 'fetch_full_detail', 0 ) ) {
$detail = $this->api->get_product( $code );
if ( ! is_wp_error( $detail ) && is_array( $detail ) ) {
$product = $this->merge_detail( $product, $detail );
} elseif ( is_wp_error( $detail ) ) {
VC_Logger::log( 'Detail fetch failed (' . $code . '): ' . $detail->get_error_message() );
}
}
$product = $this->ensure_destination( $product, $destination_id );
$inactive = $this->is_product_inactive( $product );
$result = $this->create_or_update_tour( $product, $inactive );
if ( is_wp_error( $result ) ) {
VC_Logger::error( 'Product import failed (' . $code . '): ' . $result->get_error_message() );
return 'failed';
}
update_post_meta( (int) $result, '_vc_synced_run', (string) $run_id );
if ( $inactive ) {
return 'deactivated';
}
return $existing ? 'updated' : 'imported';
}
/**
* Overlay full-detail fields onto the search summary while keeping
* summary-only fields (pricing/destinations/tags) when detail omits them.
*/
private function merge_detail( array $summary, array $detail ) {
$merged = array_replace( $summary, $detail );
foreach ( array( 'pricing', 'destinations', 'tags', 'reviews', 'images' ) as $key ) {
if ( empty( $merged[ $key ] ) && ! empty( $summary[ $key ] ) ) {
$merged[ $key ] = $summary[ $key ];
}
}
return $merged;
}
/**
* Make sure the destination we searched is present on the product so its
* taxonomy term is assigned even if the payload omits it.
*/
private function ensure_destination( array $product, $destination_id ) {
$destinations = vc_array_get( $product, 'destinations', array() );
if ( ! is_array( $destinations ) ) {
$destinations = array();
}
foreach ( $destinations as $destination ) {
$ref = vc_first_non_empty( array( vc_array_get( $destination, 'ref' ), vc_array_get( $destination, 'destinationId' ), is_scalar( $destination ) ? $destination : null ) );
if ( (string) $ref === (string) $destination_id ) {
return $product;
}
}
$destinations[] = array( 'ref' => (string) $destination_id );
$product['destinations'] = $destinations;
return $product;
}
/**
* Fetch full product detail for an already-imported post and merge it into
* the stored summary. Used for lazy, on-view enrichment so we only pay the
* per-product request for tours that are actually viewed.
*
* @return bool True on success.
*/
public function enrich_from_detail( $post_id, $product_code ) {
$product_code = sanitize_text_field( (string) $product_code );
if ( '' === $product_code ) {
return false;
}
$detail = $this->api->get_product( $product_code );
if ( is_wp_error( $detail ) || ! is_array( $detail ) ) {
if ( is_wp_error( $detail ) ) {
VC_Logger::log( 'Detail enrichment failed (' . $product_code . '): ' . $detail->get_error_message() );
}
return false;
}
$this->load_dictionaries();
$summary = vc_json_decode_meta( get_post_meta( $post_id, '_vc_raw_response', true ), array() );
$product = is_array( $summary ) && ! empty( $summary ) ? $this->merge_detail( $summary, $detail ) : $detail;
$inactive = $this->is_product_inactive( $product );
$this->save_tour_meta( $post_id, $product, $inactive );
$this->assign_taxonomies( $post_id, $product );
$this->save_image_urls( $post_id, $product );
update_post_meta( $post_id, '_vc_detail_fetched', current_time( 'mysql' ) );
return true;
}
public function import_product_by_code( $product_code ) {
$product_code = sanitize_text_field( $product_code );
if ( '' === $product_code ) {
return new WP_Error( 'vc_missing_product_code', __( 'Product code is required.', 'viator-collector' ) );
}
$product = $this->api->get_product( $product_code );
if ( is_wp_error( $product ) ) {
return $product;
}
$this->load_dictionaries();
return $this->create_or_update_tour( $product, $this->is_product_inactive( $product ) );
}
public function find_existing_tour( $product_code ) {
$query = new WP_Query(
array(
'post_type' => 'viator_tour',
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'meta_query' => array(
array(
'key' => '_vc_viator_product_code',
'value' => sanitize_text_field( (string) $product_code ),
),
),
)
);
return ! empty( $query->posts ) ? (int) $query->posts[0] : 0;
}
public function create_or_update_tour( array $product, $inactive = false ) {
$product_code = vc_first_non_empty( array( vc_array_get( $product, 'productCode' ), vc_array_get( $product, 'code' ) ) );
if ( '' === (string) $product_code ) {
return new WP_Error( 'vc_missing_product_code', __( 'Product data is missing a Viator product code.', 'viator-collector' ) );
}
$title = vc_first_non_empty( array( vc_array_get( $product, 'title' ), vc_array_get( $product, 'name' ), $product_code ) );
$desc = vc_first_non_empty( array( vc_array_get( $product, 'description' ), vc_array_get( $product, 'overview' ) ) );
$short = vc_first_non_empty( array( vc_array_get( $product, 'shortDescription' ), vc_array_get( $product, 'summary' ) ) );
if ( $inactive ) {
$status = 'draft';
} else {
$status = vc_get_option( 'default_post_status', 'publish' );
if ( ! in_array( $status, array( 'publish', 'draft', 'pending' ), true ) ) {
$status = 'publish';
}
}
$postarr = array(
'post_type' => 'viator_tour',
'post_title' => wp_strip_all_tags( $title ),
'post_content' => wp_kses_post( $desc ),
'post_excerpt' => wp_kses_post( $short ),
'post_status' => $status,
);
$post_id = $this->find_existing_tour( $product_code );
if ( $post_id ) {
$postarr['ID'] = $post_id;
$result = wp_update_post( wp_slash( $postarr ), true );
} else {
$result = wp_insert_post( wp_slash( $postarr ), true );
}
if ( is_wp_error( $result ) ) {
return $result;
}
$post_id = (int) $result;
update_post_meta( $post_id, '_vc_viator_product_code', sanitize_text_field( (string) $product_code ) );
$this->save_tour_meta( $post_id, $product, $inactive );
$this->assign_taxonomies( $post_id, $product );
$this->save_image_urls( $post_id, $product );
return $post_id;
}
public function save_tour_meta( $post_id, array $product, $inactive = false ) {
$price = vc_first_non_empty(
array(
vc_array_get( $product, 'pricing.summary.fromPrice' ),
vc_array_get( $product, 'price.fromPrice' ),
vc_array_get( $product, 'fromPrice' ),
)
);
$currency = vc_first_non_empty(
array(
vc_array_get( $product, 'pricing.currency' ),
vc_array_get( $product, 'currency' ),
vc_get_option( 'currency', 'USD' ),
)
);
$duration_minutes = vc_first_non_empty(
array(
vc_array_get( $product, 'duration.fixedDurationInMinutes' ),
vc_array_get( $product, 'duration.variableDurationFromMinutes' ),
vc_array_get( $product, 'durationMinutes' ),
)
);
$duration_text = vc_first_non_empty(
array(
vc_array_get( $product, 'duration.displayDuration' ),
vc_array_get( $product, 'durationText' ),
)
);
$booking_url = vc_first_non_empty(
array(
vc_array_get( $product, 'productUrl' ),
vc_array_get( $product, 'bookingUrl' ),
vc_array_get( $product, 'bookingLink' ),
)
);
$supplier = vc_first_non_empty(
array(
vc_array_get( $product, 'supplier.name' ),
vc_array_get( $product, 'supplierName' ),
)
);
$rating = vc_first_non_empty(
array(
vc_array_get( $product, 'reviews.combinedAverageRating' ),
vc_array_get( $product, 'rating' ),
)
);
$review_count = vc_first_non_empty(
array(
vc_array_get( $product, 'reviews.totalReviews' ),
vc_array_get( $product, 'reviewCount' ),
)
);
// Marketing flags (FREE_CANCELLATION, SPECIAL_OFFER, LIKELY_TO_SELL_OUT, …).
$flags = vc_array_get( $product, 'flags', array() );
$flags = is_array( $flags ) ? array_values( array_map( 'sanitize_text_field', $flags ) ) : array();
// Maximum group size (present in full product detail only).
$max_travelers = vc_first_non_empty(
array(
vc_array_get( $product, 'bookingRequirements.maxTravelersPerBooking' ),
vc_array_get( $product, 'bookingRequirements.maxTravelers' ),
vc_array_get( $product, 'maxTravelers' ),
)
);
$meta = array(
'_vc_booking_url' => esc_url_raw( $booking_url ),
'_vc_price_from' => $price,
'_vc_currency' => sanitize_text_field( $currency ),
'_vc_duration_minutes' => absint( $duration_minutes ),
'_vc_duration_text' => sanitize_text_field( $duration_text ),
'_vc_itinerary' => wp_json_encode( vc_array_get( $product, 'itinerary', array() ) ),
'_vc_inclusions' => wp_json_encode( vc_array_get( $product, 'inclusions', array() ) ),
'_vc_exclusions' => wp_json_encode( vc_array_get( $product, 'exclusions', array() ) ),
'_vc_additional_info' => wp_json_encode( vc_array_get( $product, 'additionalInfo', array() ) ),
'_vc_cancellation_policy' => wp_json_encode( vc_first_non_empty( array( vc_array_get( $product, 'cancellationPolicy' ), vc_array_get( $product, 'cancellation' ) ), array() ) ),
'_vc_supplier' => sanitize_text_field( $supplier ),
'_vc_destinations' => wp_json_encode( vc_array_get( $product, 'destinations', array() ) ),
'_vc_primary_destination' => VC_Structure::primary_destination_id( (array) vc_array_get( $product, 'destinations', array() ) ),
'_vc_tags' => wp_json_encode( vc_array_get( $product, 'tags', array() ) ),
'_vc_rating' => sanitize_text_field( (string) $rating ),
'_vc_review_count' => absint( $review_count ),
'_vc_flags' => wp_json_encode( $flags ),
'_vc_free_cancellation' => in_array( 'FREE_CANCELLATION', $flags, true ) ? 1 : 0,
'_vc_max_travelers' => absint( $max_travelers ),
'_vc_is_active' => $inactive ? 0 : 1,
'_vc_last_synced_at' => current_time( 'mysql' ),
);
foreach ( $meta as $key => $value ) {
update_post_meta( $post_id, $key, $value );
}
// The full API response is always stored so any field can be surfaced later.
update_post_meta( $post_id, '_vc_raw_response', wp_json_encode( $product ) );
}
public function assign_taxonomies( $post_id, array $product ) {
$destination_ids = array();
foreach ( (array) vc_array_get( $product, 'destinations', array() ) as $destination ) {
$ref = vc_first_non_empty(
array(
vc_array_get( $destination, 'ref' ),
vc_array_get( $destination, 'destinationId' ),
is_scalar( $destination ) ? $destination : null,
)
);
if ( '' === (string) $ref ) {
continue;
}
$name = isset( $this->destination_map[ (string) $ref ] ) ? $this->destination_map[ (string) $ref ] : '';
$term_id = VC_Taxonomies::get_or_create_destination_by_id( $ref, $name );
if ( $term_id ) {
$destination_ids[] = (int) $term_id;
}
}
$tag_ids = array();
foreach ( (array) vc_array_get( $product, 'tags', array() ) as $tag ) {
$id = is_scalar( $tag ) ? $tag : vc_first_non_empty( array( vc_array_get( $tag, 'tagId' ), vc_array_get( $tag, 'id' ) ) );
if ( '' === (string) $id ) {
continue;
}
$name = isset( $this->tag_map[ (string) $id ] ) ? $this->tag_map[ (string) $id ] : '';
$term_id = VC_Taxonomies::get_or_create_tag_by_id( $id, $name );
if ( $term_id ) {
$tag_ids[] = (int) $term_id;
}
}
wp_set_object_terms( $post_id, array_values( array_unique( $destination_ids ) ), 'viator_destination', false );
wp_set_object_terms( $post_id, array_values( array_unique( $tag_ids ) ), 'viator_tag', false );
}
/**
* Store image URLs (and a primary URL) without downloading anything.
*/
public function save_image_urls( $post_id, array $product ) {
$images = vc_first_non_empty(
array(
vc_array_get( $product, 'images', array() ),
vc_array_get( $product, 'media.images', array() ),
),
array()
);
$urls = array();
$primary = '';
foreach ( (array) $images as $image ) {
$url = $this->best_image_url( $image );
if ( '' === $url ) {
continue;
}
$urls[] = $url;
if ( '' === $primary && ( is_scalar( $image ) || vc_array_get( $image, 'isCover' ) ) ) {
$primary = $url;
}
}
$urls = array_values( array_unique( array_filter( $urls ) ) );
if ( '' === $primary && ! empty( $urls ) ) {
$primary = $urls[0];
}
update_post_meta( $post_id, '_vc_gallery_image_urls', wp_json_encode( $urls ) );
update_post_meta( $post_id, '_vc_primary_image_url', esc_url_raw( $primary ) );
}
/**
* Pick the highest-resolution variant available for an image entry.
*/
private function best_image_url( $image ) {
if ( is_string( $image ) ) {
return esc_url_raw( $image );
}
if ( ! is_array( $image ) ) {
return '';
}
$variants = vc_array_get( $image, 'variants', array() );
if ( is_array( $variants ) && ! empty( $variants ) ) {
$best = '';
$best_width = -1;
foreach ( $variants as $variant ) {
$url = vc_array_get( $variant, 'url' );
if ( ! $url ) {
continue;
}
$width = (int) vc_array_get( $variant, 'width', 0 );
if ( $width >= $best_width ) {
$best_width = $width;
$best = $url;
}
}
if ( '' !== $best ) {
return esc_url_raw( $best );
}
}
$url = vc_first_non_empty(
array(
vc_array_get( $image, 'url' ),
vc_array_get( $image, 'large' ),
vc_array_get( $image, 'medium' ),
vc_array_get( $image, 'imageSource' ),
)
);
return $url ? esc_url_raw( $url ) : '';
}
/**
* Detect whether the feed reports this product as no longer bookable.
*/
private function is_product_inactive( array $product ) {
$status = strtoupper( (string) vc_first_non_empty( array( vc_array_get( $product, 'status' ), vc_array_get( $product, 'productStatus' ) ) ) );
if ( '' !== $status && in_array( $status, array( 'INACTIVE', 'DEACTIVATED', 'EXPIRED', 'REMOVED', 'DELETED' ), true ) ) {
return true;
}
if ( false === vc_array_get( $product, 'active', null ) ) {
return true;
}
if ( vc_array_get( $product, 'isDeactivated', false ) ) {
return true;
}
return false;
}
}