* * @phpstan-type SlotScaling array{active: bool, upsize: bool, downsize: bool} * @phpstan-type MediaTypeBanner array{sizes: array} * @phpstan-type MediaTypeNative array{ * sizes: array, * sendTargetingKeys: bool, * title: array{required: bool}, * image: array{required: bool}, * sponsoredBy: array{required: bool}, * icon: array{required: bool}, * body: array{required: bool}, * clickUrl: array{required: bool}, * price: array{required: bool}, * cta: array{required: bool}, * privacyLink: array{required: bool}, * displayUrl: array{required: bool} * } * @phpstan-type MediaTypes array{banner?: MediaTypeBanner, native?: MediaTypeNative} * @phpstan-type SlotInjection array{ * xpath: array, * minHeightDistance: int|null, * minStartNodes: int|null, * nearSlotsCheck: array, * nearXpathsCheck: array, * nearCheckDistance: int|null, * repeat: bool, * hierarchy: string, * nearCheck: bool * } * @phpstan-type SlotConfig array{ * bidTimeout: int|null, * slotType: string|null, * margin: int|null, * nomargin: bool, * initialHeight: int, * refresh: int, * mediaTypes: MediaTypes, * injection: array, * bidders: array, * targets: array{devices: array}, * template: string|null, * displayEvent: string|null * } * @phpstan-type Slot array{ * name: string, * uuid: string, * scaling: SlotScaling, * nomargin: bool, * visible: bool, * safeFrame: bool, * initialHeight: int, * configs: array, * brand: bool, * presizedDiv: bool * } * @phpstan-type SlotsResponse array{slots: array} */ final class AdFormat { /** * @var Api */ private $api; /** * AdFormat constructor. */ public function __construct() { $this->api = new Api(); } /** * Get the slots for the current website configuration * * @since 3.2.0 * @return SlotsResponse|\WP_Error */ public function get_slots() { /** @var string $token */ $token = Options::get( 'settings-analytics-token', '' ); $website_id = Options::get( 'website-id', '' ); if ( $website_id === '' ) { return new \WP_Error( '500', 'Unable to retrieve website ID' ); } $endpoint = 'wordpress/website_configs/' . $website_id . '/slots'; $response = $this->api->get( 'api', $endpoint, array(), $token ); if ( is_wp_error( $response ) ) { return new \WP_Error( '500', 'Error while get_slots' ); } /** @var SlotsResponse $response */ return $response; } /** * Update the slots for the current website configuration * * @since 3.2.0 * * @param string $slots JSON string containing slots. * @param string $in_image * @param string $note * * @return SlotsResponse|\WP_Error */ public function update_slots( $slots, $in_image, $note = '' ) { /** @var string $token */ $token = Options::get( 'settings-analytics-token', '' ); $website_id = Options::get( 'website-id', '' ); if ( $website_id === '' ) { return new \WP_Error( '500', 'Unable to retrieve website ID' ); } $json_slots = json_decode( $slots, true ); $json_in_image = json_decode( $in_image, true ); if ( empty( $json_slots ) || ! is_array( $json_slots ) ) { return new \WP_Error( '400', 'Valid slots data is required' ); } $endpoint = 'wordpress/website_configs/' . $website_id . '/slots'; $args = array( 'slots' => $json_slots, 'inImage' => $json_in_image, 'note' => $note, ); $response = $this->api->post( 'api', $endpoint, $args, $token ); if ( is_wp_error( $response ) ) { return new \WP_Error( '500', 'Error while updating slots' ); } $this->schedule_xpath_validation(); /** @var SlotsResponse $response */ return $response; } /** * Get page types to test for a specific slot based on its configuration * * @since 3.2.0 * @param string $slot_name * @return array */ private function get_page_types_for_slot( $slot_name ) { $slot_page_types_map = array( 'corner' => array( 'post', 'page' ), 'slide-up' => array( 'post', 'page', 'home' ), 'interstitial' => array( 'post' ), 'intext' => array( 'post' ), 'top-content' => array( 'post' ), 'in-feed' => array( 'page' ), 'feed' => array( 'post' ), 'top-page' => array( 'post', 'page', 'home' ), 'aside-desktop' => array( 'post', 'page', 'home' ), ); if ( isset( $slot_page_types_map[ $slot_name ] ) ) { return $slot_page_types_map[ $slot_name ]; } return array(); } /** * Check XPath validity for all active slots across different page types * Optimized to stop testing per device once a slot passes * * @since 3.2.0 * @param array $page_types Optional array of page types to check. Default: ['home', 'post', 'page']. * @return array{success: bool, results: array, failed_active_config: bool} Array containing validation results */ public function check_slots_xpath( $page_types = array( 'home', 'post', 'page' ) ) { $slots_response = $this->get_slots(); if ( is_wp_error( $slots_response ) ) { return array( 'success' => false, 'message' => 'Failed to retrieve slots configuration: ' . $slots_response->get_error_message(), 'results' => array(), 'failed_active_config' => false, ); } if ( ! isset( $slots_response['slots'] ) || ! is_array( $slots_response['slots'] ) ) { return array( 'success' => false, 'message' => 'Invalid slots configuration received', 'results' => array(), 'failed_active_config' => false, ); } $device_slot_results = array(); $all_devices = array( 'desktop', 'tablet', 'mobile' ); $failed_active_config = false; foreach ( $all_devices as $device ) { $device_slot_results[ $device ] = array(); } $remaining_slots_per_device = array(); foreach ( $all_devices as $device ) { $remaining_slots_per_device[ $device ] = array(); foreach ( $slots_response['slots'] as $slot ) { $slot_name = isset( $slot['name'] ) ? $slot['name'] : ''; $slot_page_types = $this->get_page_types_for_slot( $slot_name ); if ( empty( $slot_page_types ) ) { continue; } $has_device_config = false; if ( isset( $slot['configs'] ) && is_array( $slot['configs'] ) ) { foreach ( $slot['configs'] as $config ) { if ( isset( $config['targets']['devices'] ) && is_array( $config['targets']['devices'] ) ) { if ( in_array( $device, $config['targets']['devices'], true ) ) { $has_device_config = true; break; } } } } if ( $has_device_config ) { $remaining_slots_per_device[ $device ][ $slot_name ] = $slot; } } } foreach ( $page_types as $page_type ) { $has_remaining_slots = false; foreach ( $all_devices as $device ) { if ( ! empty( $remaining_slots_per_device[ $device ] ) ) { $has_remaining_slots = true; break; } } if ( ! $has_remaining_slots ) { break; } $slots_for_page_type = array(); foreach ( $remaining_slots_per_device as $device => $device_slots ) { if ( ! is_array( $device_slots ) ) { continue; } foreach ( $device_slots as $slot_name => $slot ) { if ( ! is_string( $slot_name ) ) { continue; } $slot_page_types = $this->get_page_types_for_slot( $slot_name ); if ( in_array( $page_type, $slot_page_types, true ) ) { $slots_for_page_type[ $slot_name ] = $slot; } } } if ( empty( $slots_for_page_type ) ) { continue; } $this->check_page_type_xpath( $page_type, array_values( $slots_for_page_type ), $device_slot_results, $remaining_slots_per_device, $failed_active_config ); } foreach ( $all_devices as $device ) { if ( ! isset( $remaining_slots_per_device[ $device ] ) || ! is_array( $remaining_slots_per_device[ $device ] ) ) { continue; } foreach ( $remaining_slots_per_device[ $device ] as $slot_name => $slot ) { $device_slot_results[ $device ][ $slot_name ] = 'failed'; } } $optimized_results = array(); foreach ( $all_devices as $device ) { $optimized_results[ $device ] = $device_slot_results[ $device ]; } return array( 'success' => true, 'results' => $optimized_results, 'failed_active_config' => $failed_active_config, ); } /** * Check XPath validity for a specific page type with early stopping optimization * * @since 3.2.0 * @param string $page_type * @param array $slots * @param array &$device_slot_results * @param array &$remaining_slots_per_device * @param bool &$failed_active_config * @return void */ private function check_page_type_xpath( $page_type, $slots, &$device_slot_results, &$remaining_slots_per_device, &$failed_active_config ) { $page_url = $this->get_sample_page_url( $page_type ); if ( ! $page_url ) { foreach ( $slots as $slot ) { $slot_name = isset( $slot['name'] ) ? $slot['name'] : ''; if ( ! empty( $slot_name ) ) { foreach ( array( 'desktop', 'tablet', 'mobile' ) as $device ) { if ( isset( $remaining_slots_per_device[ $device ][ $slot_name ] ) ) { $device_slot_results[ $device ][ $slot_name ] = 'error'; unset( $remaining_slots_per_device[ $device ][ $slot_name ] ); } } } } return; } $page_content = $this->fetch_page_content( $page_url ); if ( ! $page_content['success'] || ! isset( $page_content['content'] ) ) { return; } $doc = new DOMDocument(); libxml_use_internal_errors( true ); $doc->loadHTML( $page_content['content'] ); libxml_clear_errors(); $xpath = new DOMXPath( $doc ); foreach ( $slots as $slot ) { $slot_name = isset( $slot['name'] ) ? $slot['name'] : ''; if ( ! isset( $slot['configs'] ) || ! is_array( $slot['configs'] ) ) { continue; } foreach ( $slot['configs'] as $config ) { if ( ! isset( $config['targets']['devices'] ) || ! is_array( $config['targets']['devices'] ) ) { continue; } $target_devices = $config['targets']['devices']; foreach ( array( 'desktop', 'mobile' ) as $device ) { if ( ! isset( $remaining_slots_per_device[ $device ][ $slot_name ] ) ) { continue; } if ( ! in_array( $device, $target_devices, true ) ) { continue; } if ( ! isset( $config['injection'] ) || ! is_array( $config['injection'] ) ) { continue; } foreach ( $config['injection'] as $injection ) { if ( ! isset( $injection['xpath'] ) || ! is_array( $injection['xpath'] ) ) { continue; } foreach ( $injection['xpath'] as $xpath_expression ) { if ( empty( $xpath_expression ) || ! is_string( $xpath_expression ) ) { continue; } if ( strpos( $xpath_expression, '//self::div[@id="hbdbrk-' ) !== false ) { $device_slot_results[ $device ][ $slot_name ] = 'success'; unset( $remaining_slots_per_device[ $device ][ $slot_name ] ); break 3; } try { /** @var string $xpath_expression */ $nodes = $xpath->query( $xpath_expression ); if ( $nodes !== false && $nodes->length > 0 ) { $device_slot_results[ $device ][ $slot_name ] = 'success'; unset( $remaining_slots_per_device[ $device ][ $slot_name ] ); break 3; } } catch ( Exception $e ) { continue; } } } } } } foreach ( $slots as $slot ) { $slot_name = isset( $slot['name'] ) && is_string( $slot['name'] ) ? $slot['name'] : ''; if ( empty( $slot_name ) || ! isset( $slot['configs'] ) || ! is_array( $slot['configs'] ) ) { continue; } foreach ( $slot['configs'] as $config ) { if ( ! isset( $config['targets']['devices'] ) || ! is_array( $config['targets']['devices'] ) ) { continue; } $target_devices = $config['targets']['devices']; foreach ( array( 'desktop', 'mobile' ) as $device ) { if ( ! isset( $remaining_slots_per_device[ $device ][ $slot_name ] ) ) { continue; } if ( ! in_array( $device, $target_devices, true ) ) { continue; } if ( isset( $page_content['content'] ) && $this->check_slot_shortcode_usage( $slot_name, $device, $page_content['content'] ) ) { $device_slot_results[ $device ][ $slot_name ] = 'success'; unset( $remaining_slots_per_device[ $device ][ $slot_name ] ); break; } if ( ! $failed_active_config && isset( $config['active'] ) && $config['active'] ) { $failed_active_config = true; } } } } } /** * Get a sample URL for testing a specific page type * * @since 3.2.0 * @param string $page_type Type of page to get URL for. * @return string|false Sample URL or false if not found */ private function get_sample_page_url( $page_type ) { // Check if WordPress is properly initialized global $wp_rewrite; if ( ! $wp_rewrite ) { return false; } try { switch ( $page_type ) { case 'home': return home_url( '/' ); case 'post': $recent_post = get_posts( array( 'numberposts' => 1, 'post_status' => 'publish', 'post_type' => 'post', ) ); if ( ! empty( $recent_post ) ) { return get_permalink( $recent_post[0]->ID ); } break; case 'page': $recent_page = get_posts( array( 'numberposts' => 1, 'post_status' => 'publish', 'post_type' => 'page', ) ); if ( ! empty( $recent_page ) ) { return get_permalink( $recent_page[0]->ID ); } break; case 'category': $categories = get_categories( array( 'number' => 1 ) ); if ( ! empty( $categories ) ) { return get_category_link( $categories[0]->term_id ); } break; } } catch ( \Exception $e ) { return false; } return false; } /** * Fetch content from a URL * * @since 3.2.0 * @param string $url URL to fetch. * @return array{success: bool, content?: string, error?: string} Fetch result */ private function fetch_page_content( $url ) { $is_local = ( strpos( $url, '.local' ) !== false ); $args = array( 'timeout' => 30, 'headers' => array( 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', ), ); if ( $is_local ) { $args['sslverify'] = false; } $response = wp_remote_get( $url, $args ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'error' => 'Failed to fetch URL: ' . $response->get_error_message(), ); } $status_code = wp_remote_retrieve_response_code( $response ); if ( $status_code !== 200 ) { return array( 'success' => false, 'error' => 'HTTP error: ' . $status_code, ); } $content = wp_remote_retrieve_body( $response ); if ( empty( $content ) ) { return array( 'success' => false, 'error' => 'Empty response body', ); } return array( 'success' => true, 'content' => $content, ); } /** * Check if a slot is using shortcode or PHP function as fallback * * @since 3.2.0 * @param string $slot_name * @param string $device * @param string $page_content * @return bool */ private function check_slot_shortcode_usage( $slot_name, $device, $page_content ) { // List of slots that support shortcode/PHP function $supported_slots = array( 'aside-desktop', 'intext', 'top-content', 'feed', 'in-feed', 'top-page' ); if ( ! in_array( $slot_name, $supported_slots, true ) ) { return false; } $expected_classes = array(); if ( $device === 'mobile' ) { $expected_classes[] = 'device-mobile'; } else { $expected_classes[] = 'device-desktop'; $expected_classes[] = 'device-tablet'; } $doc = new DOMDocument(); libxml_use_internal_errors( true ); $doc->loadHTML( $page_content ); libxml_clear_errors(); $xpath = new DOMXPath( $doc ); // Search for divs with the correct data-actirise-slot attribute $slot_divs = $xpath->query( "//div[@data-actirise-slot='" . $slot_name . "']" ); if ( $slot_divs === false || $slot_divs->length === 0 ) { return false; } // Check if any of the found divs has the correct device class foreach ( $slot_divs as $div ) { /** @var \DOMElement $div */ $class_attr = $div->getAttribute( 'class' ); if ( ! empty( $class_attr ) ) { $classes = explode( ' ', $class_attr ); foreach ( $expected_classes as $expected_class ) { if ( in_array( $expected_class, $classes, true ) ) { return true; } } } } return false; } /** * Schedule XPath validation to run shortly after slots update * * @since 3.2.0 * @return void */ private function schedule_xpath_validation() { wp_schedule_single_event( time() + 30, 'actirise_validate_xpath_after_update' ); } /** * Validate XPath expressions and store results for notifications * Called by cron after slots update or periodically * * @since 3.2.0 * @return void */ public function validate_xpath_and_notify() { $validation_results = $this->check_slots_xpath(); if ( ! $validation_results['success'] ) { return; } $failed_slots = array( 'desktop' => array(), 'mobile' => array(), ); if ( isset( $validation_results['results'] ) && is_array( $validation_results['results'] ) ) { foreach ( $validation_results['results'] as $device => $device_slots ) { if ( $device === 'tablet' ) { continue; } if ( ! in_array( $device, array( 'desktop', 'mobile' ), true ) ) { continue; } if ( ! is_array( $device_slots ) ) { continue; } foreach ( $device_slots as $slot_name => $status ) { if ( ! in_array( $status, array( 'success' ), true ) ) { $failed_slots[ $device ][] = $slot_name; } } } } $failed_active_config = isset( $validation_results['failed_active_config'] ) ? $validation_results['failed_active_config'] : false; Options::update( 'xpath_check_results', $failed_slots ); Options::update( 'xpath_failed_active_config', $failed_active_config ); } /** * Get latest XPath check results * * @since 3.2.0 * @return array{failed_slots: array} check results */ public function get_xpath_check_results() { $failed_slots = Options::get( 'xpath_check_results', array() ); if ( ! is_array( $failed_slots ) ) { $failed_slots = array(); } return array( 'failed_slots' => $failed_slots, ); } /** * Get the current config ID * * @since 3.2.0 * @param string $token API token. * @return string|false Config ID or false if not found */ public function get_config_id( $token ) { $website_id = Options::get( 'website-id', '' ); if ( empty( $website_id ) ) { return false; } $response = $this->api->get( 'api', 'websites/' . $website_id, array(), $token, true ); if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['currentConfig']['@id'] ) ) { return false; } $config_id = $response['currentConfig']['@id']; if ( ! is_string( $config_id ) ) { return false; } $id_parts = explode( '/', $config_id ); return end( $id_parts ); } }