request( 'GET', $endpoint ); } public function post( $endpoint, array $body = array() ) { return $this->request( 'POST', $endpoint, $body ); } public function request( $method, $endpoint, array $body = array() ) { $api_key = vc_get_api_key(); if ( '' === $api_key ) { return new WP_Error( 'vc_missing_api_key', __( 'Viator API key is missing.', 'viator-collector' ) ); } $this->throttle(); $base = untrailingslashit( esc_url_raw( vc_get_option( 'api_base_url', 'https://api.viator.com/partner' ) ) ); $endpoint = '/' . ltrim( $endpoint, '/' ); $url = $base . $endpoint; $timeout = max( 5, absint( vc_get_option( 'timeout', 20 ) ) ); $args = array( 'method' => strtoupper( $method ), 'timeout' => $timeout, 'headers' => array( 'Accept' => 'application/json;version=2.0', 'Accept-Language' => sanitize_text_field( vc_get_option( 'language', 'en-US' ) ), 'Content-Type' => 'application/json', 'exp-api-key' => $api_key, ), ); if ( ! empty( $body ) && 'GET' !== strtoupper( $method ) ) { $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { VC_Logger::log( 'API request failed: ' . $response->get_error_message(), true ); return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $raw = wp_remote_retrieve_body( $response ); if ( $code < 200 || $code >= 300 ) { $message = sprintf( __( 'Viator API request failed with HTTP status %d.', 'viator-collector' ), $code ); $data = json_decode( $raw, true ); if ( is_array( $data ) && ! empty( $data['message'] ) ) { $message .= ' ' . sanitize_text_field( $data['message'] ); } VC_Logger::log( $message, true ); return new WP_Error( 'vc_api_http_error', $message, array( 'status' => $code ) ); } $data = json_decode( $raw, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { return new WP_Error( 'vc_api_json_error', __( 'Viator API returned invalid JSON.', 'viator-collector' ) ); } return is_array( $data ) ? $data : array(); } /** * Block until we are under the configured per-minute request budget. * * Timestamps of recent requests are persisted in an option so the limit * holds across the separate PHP processes that WP-Cron continuation * ticks run in, not just within a single request. */ private function throttle() { $limit = max( 1, (int) vc_get_option( 'rate_limit_per_minute', 25 ) ); $now = microtime( true ); $window = get_option( 'vc_rate_window', array() ); if ( ! is_array( $window ) ) { $window = array(); } $window = array_values( array_filter( $window, static function ( $t ) use ( $now ) { return ( $now - (float) $t ) < 60.0; } ) ); if ( count( $window ) >= $limit ) { $oldest = min( $window ); $wait = 60.0 - ( $now - (float) $oldest ) + 0.05; if ( $wait > 0 ) { usleep( (int) min( 60.0, $wait ) * 1000000 ); } $now = microtime( true ); $window = array_values( array_filter( $window, static function ( $t ) use ( $now ) { return ( $now - (float) $t ) < 60.0; } ) ); } $window[] = $now; update_option( 'vc_rate_window', $window, false ); } /** * Search products within a single destination. This is the only way to * enumerate the catalogue on Basic access: a destination filter is * required and results are returned in pages (max 50 per page). * * @return array|WP_Error Decoded response, typically with `products` and `totalCount`. */ public function search_products( $destination, $start = 1, $count = 50, $currency = 'USD', array $extra_filters = array() ) { $filtering = array_merge( array( 'destination' => (string) $destination ), $extra_filters ); $body = array( 'filtering' => $filtering, 'pagination' => array( 'start' => max( 1, (int) $start ), 'count' => max( 1, min( 50, (int) $count ) ), ), 'currency' => sanitize_text_field( $currency ), ); return $this->post( '/products/search', $body ); } public function get_product( string $product_code ) { return $this->get( '/products/' . rawurlencode( $product_code ) ); } public function get_destinations() { $cache_key = 'vc_destinations_' . md5( vc_get_option( 'language', 'en-US' ) ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $data = $this->get( '/destinations' ); if ( ! is_wp_error( $data ) ) { set_transient( $cache_key, $data, 25 * HOUR_IN_SECONDS ); } return $data; } public function get_tags() { $cache_key = 'vc_tags_' . md5( vc_get_option( 'language', 'en-US' ) ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $data = $this->get( '/products/tags' ); if ( ! is_wp_error( $data ) ) { set_transient( $cache_key, $data, 25 * HOUR_IN_SECONDS ); } return $data; } /** * Destination dictionary as a map of destinationId => name. */ public function get_destination_map() { $data = $this->get_destinations(); if ( is_wp_error( $data ) ) { return $data; } $map = array(); foreach ( (array) vc_array_get( $data, 'destinations', array() ) as $destination ) { $id = vc_array_get( $destination, 'destinationId' ); $name = vc_first_non_empty( array( vc_array_get( $destination, 'name' ), vc_array_get( $destination, 'destinationName' ) ) ); if ( null !== $id && '' !== (string) $name ) { $map[ (string) $id ] = $name; } } return $map; } /** * Tag dictionary as a map of tagId => localized name. */ public function get_tag_map() { $data = $this->get_tags(); if ( is_wp_error( $data ) ) { return $data; } $lang = vc_get_option( 'language', 'en-US' ); $short = strtolower( substr( (string) $lang, 0, 2 ) ); $map = array(); foreach ( (array) vc_array_get( $data, 'tags', array() ) as $tag ) { $id = vc_array_get( $tag, 'tagId' ); $names = vc_array_get( $tag, 'allNamesByLocale', array() ); $name = ''; if ( is_array( $names ) && ! empty( $names ) ) { if ( isset( $names[ $lang ] ) ) { $name = $names[ $lang ]; } elseif ( isset( $names[ $short ] ) ) { $name = $names[ $short ]; } elseif ( isset( $names['en'] ) ) { $name = $names['en']; } else { $name = reset( $names ); } } if ( '' === (string) $name ) { $name = vc_array_get( $tag, 'name', '' ); } if ( null !== $id && '' !== (string) $name ) { $map[ (string) $id ] = $name; } } return $map; } }