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_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; } }