From 80ca8741d5847588dbed0ad98700cd0d658b96c9 Mon Sep 17 00:00:00 2001 From: John Smith Date: Sat, 5 Sep 2026 11:23:24 -0400 Subject: [PATCH] feat(ads): add ad monetization ability engine, exoclick api integration, skills, and tests --- .../abilities/ads/class-ads-integration.php | 241 ++++++ .../ads/class-ads-read-operations.php | 609 +++++++++++++ .../ads/class-ads-write-operations.php | 816 ++++++++++++++++++ .../abilities/class-ability-registrar.php | 7 + includes/class-pro-loader.php | 3 + skills/emcp-ads/SKILL.md | 131 +++ tests/test-ads-mcp.php | 463 ++++++++++ 7 files changed, 2270 insertions(+) create mode 100644 includes/abilities/ads/class-ads-integration.php create mode 100644 includes/abilities/ads/class-ads-read-operations.php create mode 100644 includes/abilities/ads/class-ads-write-operations.php create mode 100644 skills/emcp-ads/SKILL.md create mode 100644 tests/test-ads-mcp.php diff --git a/includes/abilities/ads/class-ads-integration.php b/includes/abilities/ads/class-ads-integration.php new file mode 100644 index 0000000..6f73296 --- /dev/null +++ b/includes/abilities/ads/class-ads-integration.php @@ -0,0 +1,241 @@ + __( 'Ads & Monetization Read', 'emcp-tools' ), + 'description' => sprintf( + /* translators: %1$s: comma-separated operation names */ + __( 'Read and inspect ad configurations, WP Quads slots, /ads.txt records, network detection, and ExoClick reporting. Pass the `operation` you want plus its `arguments`. If you omit `operation`, the tool returns its full operations catalog. Read operations: %1$s.', 'emcp-tools' ), + implode( ', ', $read_names ) + ), + 'category' => 'emcp-tools', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'operation' => array( + 'type' => 'string', + 'enum' => $read_names, + 'description' => __( 'The read operation to run.', 'emcp-tools' ), + ), + 'arguments' => array( + 'type' => 'object', + 'description' => __( 'Arguments passed to the chosen operation (see catalog returned when operation is omitted).', 'emcp-tools' ), + ), + ), + ), + 'output_schema' => array( 'type' => 'object' ), + 'permission_callback' => array( $this, 'can_read' ), + 'execute_callback' => array( $this, 'execute_read' ), + 'meta' => array( + 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ), + 'show_in_rest' => true, + ), + ) + ); + + emcp_tools_register_ability( + 'emcp-tools/ads-write', + array( + 'label' => __( 'Ads & Monetization Write', 'emcp-tools' ), + 'description' => sprintf( + /* translators: %1$s: comma-separated operation names */ + __( 'Create, update, or delete WP Quads ad slots with dual-write sync, edit /ads.txt with IAB validation, flush ad caches, and manage ExoClick zones. Pass the `operation` you want plus its `arguments`. If you omit `operation`, the tool returns its full operations catalog. Write operations: %1$s.', 'emcp-tools' ), + implode( ', ', $write_names ) + ), + 'category' => 'emcp-tools', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'operation' => array( + 'type' => 'string', + 'enum' => $write_names, + 'description' => __( 'The write operation to run.', 'emcp-tools' ), + ), + 'arguments' => array( + 'type' => 'object', + 'description' => __( 'Arguments passed to the chosen operation (see catalog returned when operation is omitted).', 'emcp-tools' ), + ), + ), + ), + 'output_schema' => array( 'type' => 'object' ), + 'permission_callback' => array( $this, 'can_write' ), + 'execute_callback' => array( $this, 'execute_write' ), + 'meta' => array( + 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Build discovery-catalog response. + * + * @param string $tool + * @param string $description + * @param array $spec + * @return array + */ + private function catalog( string $tool, string $description, array $spec ): array { + $operations = array(); + + foreach ( $spec as $name => $entry ) { + $schema = (array) ( $entry['schema'] ?? array() ); + $required = (array) ( $schema['required'] ?? array() ); + $props = (array) ( $schema['properties'] ?? array() ); + $args = array(); + + foreach ( $props as $arg_name => $prop ) { + $args[ $arg_name ] = self::argument_hint( (array) $prop, in_array( $arg_name, $required, true ) ); + } + + $operations[ $name ] = array( + 'description' => (string) ( $entry['description'] ?? $name ), + 'arguments' => $args, + 'required' => $required, + 'schema' => $schema, + 'example' => (array) ( $entry['example'] ?? array() ), + ); + } + + return array( + 'tool' => $tool, + 'description' => $description, + 'operations' => $operations, + ); + } + + /** + * Short human hint for schema property. + * + * @param array $prop + * @param bool $required + * @return string + */ + private static function argument_hint( array $prop, bool $required ): string { + $type = isset( $prop['type'] ) ? $prop['type'] : 'mixed'; + if ( is_array( $type ) ) { + $type = implode( '|', $type ); + } + $hint = str_replace( 'integer', 'int', (string) $type ); + + if ( ! empty( $prop['enum'] ) && is_array( $prop['enum'] ) ) { + $hint .= ' (enum: ' . implode( '|', $prop['enum'] ) . ')'; + } + if ( array_key_exists( 'default', $prop ) ) { + $hint .= ' (default: ' . ( is_bool( $prop['default'] ) ? ( $prop['default'] ? 'true' : 'false' ) : (string) $prop['default'] ) . ')'; + } + if ( $required ) { + $hint .= ' (required)'; + } + + return $hint; + } + + /** + * Permission check for read operations. + * + * @return bool + */ + public function can_read(): bool { + return current_user_can( 'read' ) || current_user_can( 'edit_posts' ); + } + + /** + * Permission check for write operations. + * + * @return bool + */ + public function can_write(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Execute read operation. + * + * @param array $args + * @return array|\WP_Error + */ + public function execute_read( array $args ) { + $op = trim( (string) ( $args['operation'] ?? '' ) ); + $in = (array) ( $args['arguments'] ?? array() ); + + if ( '' === $op ) { + return $this->catalog( + 'emcp-tools/ads-read', + __( 'Read and inspect ad configurations, WP Quads slots, /ads.txt records, and ExoClick reporting.', 'emcp-tools' ), + EMCP_Tools_Ads_Read_Operations::op_schema() + ); + } + + return EMCP_Tools_Ads_Read_Operations::execute( $op, $in ); + } + + /** + * Execute write operation. + * + * @param array $args + * @return array|\WP_Error + */ + public function execute_write( array $args ) { + $op = trim( (string) ( $args['operation'] ?? '' ) ); + $in = (array) ( $args['arguments'] ?? array() ); + + if ( '' === $op ) { + return $this->catalog( + 'emcp-tools/ads-write', + __( 'Create, update, or delete WP Quads ad slots with dual-write sync, edit /ads.txt, flush ad caches, and manage ExoClick zones.', 'emcp-tools' ), + EMCP_Tools_Ads_Write_Operations::op_schema() + ); + } + + return EMCP_Tools_Ads_Write_Operations::execute( $op, $in ); + } +} diff --git a/includes/abilities/ads/class-ads-read-operations.php b/includes/abilities/ads/class-ads-read-operations.php new file mode 100644 index 0000000..71506d1 --- /dev/null +++ b/includes/abilities/ads/class-ads-read-operations.php @@ -0,0 +1,609 @@ + + */ + public static function op_schema(): array { + return array( + 'list-ads' => array( + 'description' => __( 'List all registered ad units from WP Quads and active ad locations with detected networks and dimensions.', 'emcp-tools' ), + 'example' => array( 'status' => 'all' ), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'enum' => array( 'all', 'active', 'inactive' ), + 'default' => 'all', + 'description' => __( 'Filter ads by status.', 'emcp-tools' ), + ), + ), + ), + ), + 'get-ad' => array( + 'description' => __( 'Get complete configuration, dimensions, code, and placement for a specific ad slot by Post ID or Quads key (e.g. ad1).', 'emcp-tools' ), + 'example' => array( 'id' => 'ad1' ), + 'schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'description' => __( 'Post ID (e.g. "13595") or WP Quads slot key (e.g. "ad1", "ad2").', 'emcp-tools' ), + ), + ), + ), + ), + 'get-ads-txt' => array( + 'description' => __( 'Read and parse /ads.txt records, validate IAB compliance, and check authorized sellers.', 'emcp-tools' ), + 'example' => array(), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'validate_syntax' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to validate IAB formatting for each record.', 'emcp-tools' ), + ), + ), + ), + ), + 'audit-monetization' => array( + 'description' => __( 'Run comprehensive diagnostic audit on site ads: check ads.txt health, detect duplicate pop scripts, inspect mobile responsive styling, and verify affiliate cache status.', 'emcp-tools' ), + 'example' => array(), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'check_remote' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Perform live HTTP check on /ads.txt endpoint.', 'emcp-tools' ), + ), + ), + ), + ), + 'exoclick-list-zones' => array( + 'description' => __( 'Query ExoClick REST API to list all ad zones for a site ID, showing dimensions, refresh rates, and active status.', 'emcp-tools' ), + 'example' => array( 'idsite' => 1111220 ), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'api_token' => array( + 'type' => 'string', + 'description' => __( 'ExoClick API token (optional if configured in settings).', 'emcp-tools' ), + ), + 'idsite' => array( + 'type' => 'integer', + 'description' => __( 'ExoClick Site ID (defaults to saved site ID).', 'emcp-tools' ), + ), + ), + ), + ), + 'exoclick-get-stats' => array( + 'description' => __( 'Query ExoClick reporting API for impression, click, CTR, eCPM, and revenue metrics.', 'emcp-tools' ), + 'example' => array( 'date_from' => '2026-09-01', 'date_to' => '2026-09-05' ), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'api_token' => array( + 'type' => 'string', + 'description' => __( 'ExoClick API token (optional if configured in settings).', 'emcp-tools' ), + ), + 'date_from' => array( + 'type' => 'string', + 'description' => __( 'Start date in YYYY-MM-DD format.', 'emcp-tools' ), + ), + 'date_to' => array( + 'type' => 'string', + 'description' => __( 'End date in YYYY-MM-DD format.', 'emcp-tools' ), + ), + ), + ), + ), + ); + } + + /** + * Detect ad network provider from raw HTML/JS code. + * + * @param string $code Raw ad markup. + * @return array{network:string,zone_id:?string,dimensions:?string} + */ + public static function parse_ad_code( string $code ): array { + $network = 'Custom'; + $zone_id = null; + $dimensions = null; + + if ( preg_match( '/data-zoneid=["\'](\d+)["\']/i', $code, $matches ) || preg_match( '/idzone["\']?\s*[:=]\s*(\d+)/i', $code, $matches ) ) { + $network = 'ExoClick'; + $zone_id = $matches[1]; + } elseif ( preg_match( '/(?:adzone|adzone_id|jads\.js|juicyads\.com)[^\d]*(\d{5,8})/i', $code, $matches ) ) { + $network = 'JuicyAds'; + $zone_id = $matches[1]; + } elseif ( stripos( $code, 'trafficstars' ) !== false || preg_match( '/trafficstars\.com[^\d]*(\d+)/i', $code, $matches ) ) { + $network = 'TrafficStars'; + $zone_id = $matches[1] ?? null; + } elseif ( stripos( $code, 'chaturbate' ) !== false || stripos( $code, 'cbxyz' ) !== false ) { + $network = 'Chaturbate'; + } elseif ( stripos( $code, 'googlesyndication' ) !== false || stripos( $code, 'adsbygoogle' ) !== false ) { + $network = 'Google AdSense'; + } + + if ( preg_match( '/(?:data-width|width)=["\']?(\d+)["\']?\s+(?:data-height|height)=["\']?(\d+)["\']?/i', $code, $dim_matches ) ) { + $dimensions = "{$dim_matches[1]}x{$dim_matches[2]}"; + } elseif ( preg_match( '/(\d{2,4})x(\d{2,4})/', $code, $dim_matches ) ) { + $dimensions = "{$dim_matches[1]}x{$dim_matches[2]}"; + } + + return array( + 'network' => $network, + 'zone_id' => $zone_id, + 'dimensions' => $dimensions, + ); + } + + /** + * Execute read operations. + * + * @param string $op Operation name. + * @param array $args Operation arguments. + * @return array + */ + public static function execute( string $op, array $args = array() ): array { + switch ( $op ) { + case 'list-ads': + return self::list_ads( $args ); + case 'get-ad': + return self::get_ad( $args ); + case 'get-ads-txt': + return self::get_ads_txt( $args ); + case 'audit-monetization': + return self::audit_monetization( $args ); + case 'exoclick-list-zones': + return self::exoclick_list_zones( $args ); + case 'exoclick-get-stats': + return self::exoclick_get_stats( $args ); + default: + return array( + 'success' => false, + 'error' => sprintf( 'Unknown read operation: %s', $op ), + ); + } + } + + /** + * List all configured ad slots. + * + * @param array $args + * @return array + */ + public static function list_ads( array $args = array() ): array { + $filter_status = $args['status'] ?? 'all'; + $ads = array(); + + $settings = get_option( 'quads_settings' ); + $slots = is_array( $settings ) && isset( $settings['ads'] ) ? (array) $settings['ads'] : array(); + + $posts = function_exists( 'get_posts' ) ? get_posts( + array( + 'post_type' => 'quads-ads', + 'post_status' => 'any', + 'posts_per_page' => 100, + ) + ) : array(); + + $post_map = array(); + foreach ( $posts as $p ) { + $old_id = get_post_meta( $p->ID, 'quads_ad_old_id', true ) ?: ''; + if ( ! empty( $old_id ) ) { + $post_map[ $old_id ] = $p; + } + $post_map[ 'id_' . $p->ID ] = $p; + } + + foreach ( $slots as $slot_key => $slot_data ) { + if ( ! is_array( $slot_data ) ) { + continue; + } + + $post = $post_map[ $slot_key ] ?? null; + $post_id = $post ? (int) $post->ID : 0; + $code = $slot_data['code'] ?? ( $post_id ? (string) get_post_meta( $post_id, 'code', true ) : '' ); + $parsed = self::parse_ad_code( $code ); + $title = $slot_data['label'] ?? ( $post ? $post->post_title : $slot_key ); + $position = $slot_data['position'] ?? ( $post_id ? (string) get_post_meta( $post_id, 'position', true ) : 'unknown' ); + $is_active = ! empty( $code ); + + if ( 'active' === $filter_status && ! $is_active ) { + continue; + } + if ( 'inactive' === $filter_status && $is_active ) { + continue; + } + + $ads[] = array( + 'slot_key' => $slot_key, + 'post_id' => $post_id, + 'title' => $title, + 'position' => $position, + 'detected_network' => $parsed['network'], + 'zone_id' => $parsed['zone_id'], + 'dimensions' => $parsed['dimensions'] ?: ( $slot_data['dimensions'] ?? null ), + 'is_active' => $is_active, + 'code_preview' => mb_substr( trim( $code ), 0, 150 ) . ( mb_strlen( $code ) > 150 ? '...' : '' ), + ); + } + + return array( + 'success' => true, + 'total_ads' => count( $ads ), + 'ads' => $ads, + 'quads_mode' => get_option( 'quads-mode', 'new' ), + ); + } + + /** + * Get details for a specific ad slot. + * + * @param array $args + * @return array + */ + public static function get_ad( array $args ): array { + $id = trim( (string) ( $args['id'] ?? '' ) ); + if ( '' === $id ) { + return array( 'success' => false, 'error' => 'Argument "id" is required.' ); + } + + $settings = get_option( 'quads_settings', array() ); + $slot_data = null; + $post_id = 0; + $slot_key = null; + + if ( isset( $settings['ads'][ $id ] ) && is_array( $settings['ads'][ $id ] ) ) { + $slot_data = $settings['ads'][ $id ]; + $slot_key = $id; + if ( function_exists( 'get_posts' ) ) { + $found = get_posts( + array( + 'post_type' => 'quads-ads', + 'meta_key' => 'quads_ad_old_id', + 'meta_value' => $slot_key, + 'numberposts' => 1, + ) + ); + if ( ! empty( $found ) ) { + $post_id = $found[0]->ID; + } + } + } elseif ( is_numeric( $id ) && function_exists( 'get_post' ) ) { + $p = get_post( (int) $id ); + if ( $p && 'quads-ads' === $p->post_type ) { + $post_id = $p->ID; + $old_id = (string) get_post_meta( $post_id, 'quads_ad_old_id', true ); + if ( ! empty( $old_id ) && isset( $settings['ads'][ $old_id ] ) ) { + $slot_data = $settings['ads'][ $old_id ]; + $slot_key = $old_id; + } + } + } + + if ( ! $slot_data && ! $post_id ) { + return array( 'success' => false, 'error' => sprintf( 'Ad slot "%s" not found.', $id ) ); + } + + $code = $slot_data['code'] ?? ( $post_id ? (string) get_post_meta( $post_id, 'code', true ) : '' ); + + return array( + 'success' => true, + 'slot_key' => $slot_key ?? $id, + 'post_id' => $post_id, + 'title' => $slot_data['label'] ?? ( $post_id ? get_the_title( $post_id ) : '' ), + 'code' => $code, + 'parsed' => self::parse_ad_code( $code ), + 'settings' => $slot_data ?: array(), + 'postmeta' => $post_id && function_exists( 'get_post_meta' ) ? get_post_meta( $post_id ) : array(), + ); + } + + /** + * Parse and validate ads.txt. + * + * @param array $args + * @return array + */ + public static function get_ads_txt( array $args = array() ): array { + $raw = ''; + + $option = get_option( 'emcp_tools_ads_txt' ); + if ( is_string( $option ) && ! empty( $option ) ) { + $raw = $option; + } + + if ( empty( $raw ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'ads.txt' ) && is_readable( ABSPATH . 'ads.txt' ) ) { + $raw = (string) file_get_contents( ABSPATH . 'ads.txt' ); + } + + if ( empty( $raw ) && function_exists( 'wp_remote_get' ) ) { + $resp = wp_remote_get( home_url( '/ads.txt' ), array( 'timeout' => 4 ) ); + if ( ! is_wp_error( $resp ) && 200 === wp_remote_retrieve_response_code( $resp ) ) { + $raw = (string) wp_remote_retrieve_body( $resp ); + } + } + + $records = array(); + $lines = explode( "\n", str_replace( "\r", '', $raw ) ); + $warnings = array(); + + foreach ( $lines as $idx => $line ) { + $trimmed = trim( $line ); + if ( '' === $trimmed || 0 === strpos( $trimmed, '#' ) ) { + continue; + } + + $parts = array_map( 'trim', explode( ',', $trimmed ) ); + if ( count( $parts ) < 3 ) { + $warnings[] = sprintf( 'Line %d has invalid format (<3 comma-separated fields): "%s"', $idx + 1, $trimmed ); + continue; + } + + $domain = strtolower( $parts[0] ); + $pub_id = $parts[1]; + $relation = strtoupper( $parts[2] ); + $cert_id = $parts[3] ?? null; + + $valid_relation = in_array( $relation, array( 'DIRECT', 'RESELLER' ), true ); + if ( ! $valid_relation ) { + $warnings[] = sprintf( 'Line %d has invalid relationship "%s" (must be DIRECT or RESELLER)', $idx + 1, $relation ); + } + + $records[] = array( + 'line_number' => $idx + 1, + 'domain' => $domain, + 'publisher_id' => $pub_id, + 'relationship' => $relation, + 'certification_authority_id' => $cert_id, + 'is_valid' => $valid_relation && ! empty( $domain ) && ! empty( $pub_id ), + ); + } + + return array( + 'success' => true, + 'raw' => $raw, + 'total_records' => count( $records ), + 'records' => $records, + 'warnings' => $warnings, + 'source' => ! empty( $option ) ? 'option' : ( defined( 'ABSPATH' ) && file_exists( ABSPATH . 'ads.txt' ) ? 'file' : 'live_url' ), + ); + } + + /** + * Run comprehensive monetization audit. + * + * @param array $args + * @return array + */ + public static function audit_monetization( array $args = array() ): array { + $issues = array(); + $recommendations = array(); + + $ads_txt = self::get_ads_txt(); + if ( 0 === $ads_txt['total_records'] ) { + $issues[] = 'No ads.txt records found. Major SSPs and programmatic buyers will refuse to bid on site inventory.'; + $recommendations[] = 'Configure /ads.txt with authorized publisher lines for active networks (ExoClick, TrafficStars, JuicyAds).'; + } elseif ( ! empty( $ads_txt['warnings'] ) ) { + $issues[] = sprintf( 'Found %d warnings in /ads.txt syntax.', count( $ads_txt['warnings'] ) ); + } + + $ads_list = self::list_ads(); + $active_ads = array_filter( $ads_list['ads'], fn( $ad ) => ! empty( $ad['is_active'] ) ); + + if ( 0 === count( $active_ads ) ) { + $issues[] = 'No active ad units found in WP Quads.'; + $recommendations[] = 'Create responsive banner placements (Above Comic 728x90, Under Comic 300x250, Sidebar 160x600).'; + } + + $juicy_count = 0; + $exo_count = 0; + foreach ( $active_ads as $ad ) { + if ( 'JuicyAds' === $ad['detected_network'] ) { + $juicy_count++; + } elseif ( 'ExoClick' === $ad['detected_network'] ) { + $exo_count++; + } + } + + if ( $juicy_count > 0 && $exo_count > 0 ) { + $issues[] = sprintf( 'Site is running a hybrid setup (%d ExoClick, %d JuicyAds). Consider standardizing on ExoClick to avoid remnant backfill and multiple external JS dependencies.', $exo_count, $juicy_count ); + } + + return array( + 'success' => true, + 'status' => empty( $issues ) ? 'healthy' : 'attention_required', + 'active_ad_units' => count( $active_ads ), + 'ads_txt_records' => $ads_txt['total_records'], + 'issues' => $issues, + 'recommendations' => $recommendations, + ); + } + + /** + * Helper: Resolve ExoClick API token. + * + * @param array $args + * @return string|null + */ + public static function get_exoclick_token( array $args = array() ): ?string { + if ( ! empty( $args['api_token'] ) && is_string( $args['api_token'] ) ) { + return trim( $args['api_token'] ); + } + if ( defined( 'EXOCLICK_API_TOKEN' ) && ! empty( EXOCLICK_API_TOKEN ) ) { + return EXOCLICK_API_TOKEN; + } + $opt = get_option( 'emcp_tools_exoclick_api_token' ); + return ! empty( $opt ) && is_string( $opt ) ? $opt : null; + } + + /** + * Query ExoClick API to list zones. + * + * @param array $args + * @return array + */ + public static function exoclick_list_zones( array $args = array() ): array { + $token = self::get_exoclick_token( $args ); + if ( ! $token ) { + return array( + 'success' => false, + 'error' => 'ExoClick API token is required. Pass "api_token" or configure "emcp_tools_exoclick_api_token".', + ); + } + + $bearer = self::authenticate_exoclick( $token ); + if ( is_wp_error( $bearer ) ) { + return array( 'success' => false, 'error' => $bearer->get_error_message() ); + } + + $site_id = $args['idsite'] ?? get_option( 'emcp_tools_exoclick_site_id', 1111220 ); + $url = 'https://api.exoclick.com/v2/zones' . ( $site_id ? '?idsite=' . absint( $site_id ) : '' ); + + $res = wp_remote_get( + $url, + array( + 'headers' => array( + 'Authorization' => 'Bearer ' . $bearer, + 'User-Agent' => 'EMCP-Tools/WordPress', + ), + 'timeout' => 10, + ) + ); + + if ( is_wp_error( $res ) ) { + return array( 'success' => false, 'error' => $res->get_error_message() ); + } + + $data = json_decode( wp_remote_retrieve_body( $res ), true ); + if ( ! is_array( $data ) ) { + return array( 'success' => false, 'error' => 'Invalid JSON from ExoClick API.' ); + } + + return array( + 'success' => true, + 'site_id' => $site_id, + 'zones' => $data['result'] ?? $data, + ); + } + + /** + * Query ExoClick API for reporting statistics. + * + * @param array $args + * @return array + */ + public static function exoclick_get_stats( array $args = array() ): array { + $token = self::get_exoclick_token( $args ); + if ( ! $token ) { + return array( + 'success' => false, + 'error' => 'ExoClick API token is required.', + ); + } + + $bearer = self::authenticate_exoclick( $token ); + if ( is_wp_error( $bearer ) ) { + return array( 'success' => false, 'error' => $bearer->get_error_message() ); + } + + $date_from = $args['date_from'] ?? gmdate( 'Y-m-d', strtotime( '-7 days' ) ); + $date_to = $args['date_to'] ?? gmdate( 'Y-m-d' ); + $url = sprintf( 'https://api.exoclick.com/v2/statistics/p/zone?date_from=%s&date_to=%s', urlencode( $date_from ), urlencode( $date_to ) ); + + $res = wp_remote_get( + $url, + array( + 'headers' => array( + 'Authorization' => 'Bearer ' . $bearer, + 'User-Agent' => 'EMCP-Tools/WordPress', + ), + 'timeout' => 15, + ) + ); + + if ( is_wp_error( $res ) ) { + return array( 'success' => false, 'error' => $res->get_error_message() ); + } + + $data = json_decode( wp_remote_retrieve_body( $res ), true ); + return array( + 'success' => true, + 'date_from' => $date_from, + 'date_to' => $date_to, + 'stats' => $data['result'] ?? $data, + ); + } + + /** + * Authenticate API token and return Bearer session token with transient caching. + * + * @param string $api_token + * @return string|\WP_Error + */ + public static function authenticate_exoclick( string $api_token ) { + $cache_key = 'emcp_exoclick_bearer_' . md5( $api_token ); + $cached = function_exists( 'get_transient' ) ? get_transient( $cache_key ) : false; + if ( ! empty( $cached ) && is_string( $cached ) ) { + return $cached; + } + + $url = 'https://api.exoclick.com/v2/login'; + $body = wp_json_encode( array( 'api_token' => $api_token ) ); + + $res = wp_remote_post( + $url, + array( + 'headers' => array( + 'Content-Type' => 'application/json', + 'User-Agent' => 'EMCP-Tools/WordPress', + ), + 'body' => $body, + 'timeout' => 10, + ) + ); + + if ( is_wp_error( $res ) ) { + return $res; + } + + $data = json_decode( wp_remote_retrieve_body( $res ), true ); + if ( empty( $data['token'] ) ) { + return new \WP_Error( 'auth_failed', $data['message'] ?? 'ExoClick API authentication failed.' ); + } + + $bearer_token = $data['token']; + $ttl = isset( $data['expires_in'] ) ? max( 60, (int) $data['expires_in'] - 120 ) : 3600; + if ( function_exists( 'set_transient' ) ) { + set_transient( $cache_key, $bearer_token, $ttl ); + } + + return $bearer_token; + } +} diff --git a/includes/abilities/ads/class-ads-write-operations.php b/includes/abilities/ads/class-ads-write-operations.php new file mode 100644 index 0000000..51dc54f --- /dev/null +++ b/includes/abilities/ads/class-ads-write-operations.php @@ -0,0 +1,816 @@ + + */ + public static function op_schema(): array { + return array( + 'update-ad' => array( + 'description' => __( 'Update an existing ad unit with dual-write synchronization across WP Quads postmeta and quads_settings.', 'emcp-tools' ), + 'example' => array( + 'id' => 'ad1', + 'code' => '', + ), + 'schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'description' => __( 'Slot key (e.g. "ad1") or post ID (e.g. "13595").', 'emcp-tools' ), + ), + 'code' => array( + 'type' => 'string', + 'description' => __( 'New HTML/JS tag markup for this ad unit.', 'emcp-tools' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => __( 'Optional new label/title for the ad unit.', 'emcp-tools' ), + ), + 'position' => array( + 'type' => 'string', + 'description' => __( 'Placement position (e.g. "custom", "before_post", "after_post").', 'emcp-tools' ), + ), + 'dimensions' => array( + 'type' => 'string', + 'description' => __( 'Ad dimensions (e.g. "728x90", "300x250").', 'emcp-tools' ), + ), + 'settings' => array( + 'type' => 'object', + 'description' => __( 'Additional WP Quads settings parameters.', 'emcp-tools' ), + ), + 'purge_cache' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to purge page cache and object cache after updating.', 'emcp-tools' ), + ), + ), + ), + ), + 'create-ad' => array( + 'description' => __( 'Create a new ad unit with dual-write to WP Quads posts and serialized options.', 'emcp-tools' ), + 'example' => array( + 'title' => 'Footer Ad', + 'code' => '', + 'position' => 'custom', + ), + 'schema' => array( + 'type' => 'object', + 'required' => array( 'title', 'code' ), + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'description' => __( 'Name / label for the ad slot.', 'emcp-tools' ), + ), + 'code' => array( + 'type' => 'string', + 'description' => __( 'HTML/JS tag markup for the ad unit.', 'emcp-tools' ), + ), + 'slot_key' => array( + 'type' => 'string', + 'description' => __( 'WP Quads slot key (e.g. "ad12"). If omitted, auto-generated based on existing keys.', 'emcp-tools' ), + ), + 'position' => array( + 'type' => 'string', + 'default' => 'custom', + 'description' => __( 'Placement position.', 'emcp-tools' ), + ), + 'dimensions' => array( + 'type' => 'string', + 'description' => __( 'Dimensions string (e.g. "728x90").', 'emcp-tools' ), + ), + 'settings' => array( + 'type' => 'object', + 'description' => __( 'Additional WP Quads options dict.', 'emcp-tools' ), + ), + 'purge_cache' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to flush cache after creation.', 'emcp-tools' ), + ), + ), + ), + ), + 'delete-ad' => array( + 'description' => __( 'Delete or deactivate an ad unit from WP Quads and quads_settings with safety confirmation guard.', 'emcp-tools' ), + 'example' => array( + 'id' => 'ad12', + 'confirm' => true, + ), + 'schema' => array( + 'type' => 'object', + 'required' => array( 'id', 'confirm' ), + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'description' => __( 'Slot key or post ID to delete.', 'emcp-tools' ), + ), + 'confirm' => array( + 'type' => 'boolean', + 'description' => __( 'Safety guard: must explicitly be true to execute deletion.', 'emcp-tools' ), + ), + 'purge_cache' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to flush cache after deletion.', 'emcp-tools' ), + ), + ), + ), + ), + 'set-ads-txt' => array( + 'description' => __( 'Set or append records to /ads.txt with IAB syntax validation and physical/option dual-write.', 'emcp-tools' ), + 'example' => array( + 'append_records' => array( 'exoclick.com, 1111220, DIRECT, f6e6255c27770857' ), + ), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'description' => __( 'Full text content to replace /ads.txt completely.', 'emcp-tools' ), + ), + 'append_records' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => __( 'List of individual record lines to append to existing /ads.txt.', 'emcp-tools' ), + ), + 'backup' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to save previous content to backup option before changing.', 'emcp-tools' ), + ), + 'purge_cache' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to flush page cache after updating ads.txt.', 'emcp-tools' ), + ), + ), + ), + ), + 'purge-ad-cache' => array( + 'description' => __( 'Flush LiteSpeed page cache, WP object cache, and temporary ExoClick API transients.', 'emcp-tools' ), + 'example' => array( 'all' => true ), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'all' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Flush all ad and site caches.', 'emcp-tools' ), + ), + ), + ), + ), + 'exoclick-create-zone' => array( + 'description' => __( 'Create a new ad zone on ExoClick via API and optionally install tag directly into WP Quads slot.', 'emcp-tools' ), + 'example' => array( + 'name' => 'Header Banner', + 'dimensions' => '728x90', + 'idsite' => 1111220, + ), + 'schema' => array( + 'type' => 'object', + 'required' => array( 'name' ), + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => __( 'Zone name (e.g. "Above Comic - 728x90").', 'emcp-tools' ), + ), + 'dimensions' => array( + 'type' => 'string', + 'description' => __( 'Dimensions (e.g. "728x90", "300x250", "160x600").', 'emcp-tools' ), + ), + 'idsub_type_format' => array( + 'type' => 'integer', + 'description' => __( 'ExoClick sub type format ID (e.g. 1 for banner, 7 for popunder).', 'emcp-tools' ), + ), + 'idsite' => array( + 'type' => 'integer', + 'description' => __( 'ExoClick Site ID (defaults to saved site ID).', 'emcp-tools' ), + ), + 'api_token' => array( + 'type' => 'string', + 'description' => __( 'ExoClick API token (optional if configured).', 'emcp-tools' ), + ), + 'install_to_slot' => array( + 'type' => 'string', + 'description' => __( 'WP Quads slot key (e.g. "ad1") or "create" to automatically install generated tag.', 'emcp-tools' ), + ), + ), + ), + ), + 'exoclick-verify-site' => array( + 'description' => __( 'Verify website ownership with ExoClick by deploying verification token and triggering API confirmation.', 'emcp-tools' ), + 'example' => array( 'idsite' => 1111220 ), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'idsite' => array( + 'type' => 'integer', + 'description' => __( 'ExoClick Site ID.', 'emcp-tools' ), + ), + 'api_token' => array( + 'type' => 'string', + 'description' => __( 'ExoClick API token (optional if configured).', 'emcp-tools' ), + ), + ), + ), + ), + ); + } + + /** + * Execute write operations. + * + * @param string $op Operation name. + * @param array $args Operation arguments. + * @return array + */ + public static function execute( string $op, array $args = array() ): array { + switch ( $op ) { + case 'update-ad': + return self::update_ad( $args ); + case 'create-ad': + return self::create_ad( $args ); + case 'delete-ad': + return self::delete_ad( $args ); + case 'set-ads-txt': + return self::set_ads_txt( $args ); + case 'purge-ad-cache': + return self::purge_ad_cache( $args ); + case 'exoclick-create-zone': + return self::exoclick_create_zone( $args ); + case 'exoclick-verify-site': + return self::exoclick_verify_site( $args ); + default: + return array( + 'success' => false, + 'error' => sprintf( 'Unknown write operation: %s', $op ), + ); + } + } + + /** + * Update an ad slot with dual-write sync. + * + * @param array $args + * @return array + */ + public static function update_ad( array $args ): array { + $id = trim( (string) ( $args['id'] ?? '' ) ); + if ( '' === $id ) { + return array( 'success' => false, 'error' => 'Argument "id" is required.' ); + } + + $settings = get_option( 'quads_settings', array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + if ( ! isset( $settings['ads'] ) || ! is_array( $settings['ads'] ) ) { + $settings['ads'] = array(); + } + + $post_id = 0; + $slot_key = null; + $post = null; + + if ( isset( $settings['ads'][ $id ] ) ) { + $slot_key = $id; + if ( function_exists( 'get_posts' ) ) { + $found = get_posts( + array( + 'post_type' => 'quads-ads', + 'meta_key' => 'quads_ad_old_id', + 'meta_value' => $slot_key, + 'numberposts' => 1, + ) + ); + if ( ! empty( $found ) ) { + $post = $found[0]; + $post_id = $post->ID; + } + } + } elseif ( is_numeric( $id ) && function_exists( 'get_post' ) ) { + $p = get_post( (int) $id ); + if ( $p && 'quads-ads' === $p->post_type ) { + $post = $p; + $post_id = $p->ID; + $old_id = (string) get_post_meta( $post_id, 'quads_ad_old_id', true ); + $slot_key = ! empty( $old_id ) ? $old_id : 'ad_' . $post_id; + } + } + + if ( ! $slot_key && ! $post_id ) { + return array( 'success' => false, 'error' => sprintf( 'Ad slot "%s" not found to update.', $id ) ); + } + + $current_slot_data = $settings['ads'][ $slot_key ] ?? array(); + if ( ! is_array( $current_slot_data ) ) { + $current_slot_data = array(); + } + + $new_code = isset( $args['code'] ) ? (string) $args['code'] : ( $current_slot_data['code'] ?? '' ); + $new_title = isset( $args['title'] ) ? (string) $args['title'] : ( $current_slot_data['label'] ?? ( $post ? $post->post_title : $slot_key ) ); + $new_position = isset( $args['position'] ) ? (string) $args['position'] : ( $current_slot_data['position'] ?? 'custom' ); + $new_dimensions = isset( $args['dimensions'] ) ? (string) $args['dimensions'] : ( $current_slot_data['dimensions'] ?? '' ); + + // 1. Dual-write to quads_settings + $current_slot_data['code'] = $new_code; + $current_slot_data['label'] = $new_title; + $current_slot_data['position'] = $new_position; + if ( ! empty( $new_dimensions ) ) { + $current_slot_data['dimensions'] = $new_dimensions; + } + if ( isset( $args['settings'] ) && is_array( $args['settings'] ) ) { + $current_slot_data = array_merge( $current_slot_data, $args['settings'] ); + } + $settings['ads'][ $slot_key ] = $current_slot_data; + update_option( 'quads_settings', $settings ); + + // 2. Dual-write to quads-ads post if exists + if ( $post_id && function_exists( 'update_post_meta' ) ) { + update_post_meta( $post_id, 'code', $new_code ); + update_post_meta( $post_id, 'position', $new_position ); + if ( ! empty( $new_dimensions ) ) { + update_post_meta( $post_id, 'dimensions', $new_dimensions ); + } + if ( isset( $args['title'] ) && function_exists( 'wp_update_post' ) ) { + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => $new_title, + ) + ); + } + } + + // 3. Cache flush + if ( $args['purge_cache'] ?? true ) { + self::purge_ad_cache(); + } + + return array( + 'success' => true, + 'slot_key' => $slot_key, + 'post_id' => $post_id, + 'title' => $new_title, + 'detected_network' => EMCP_Tools_Ads_Read_Operations::parse_ad_code( $new_code ), + 'message' => sprintf( 'Ad slot "%s" updated successfully.', $slot_key ), + ); + } + + /** + * Create a new ad slot with dual-write sync. + * + * @param array $args + * @return array + */ + public static function create_ad( array $args ): array { + $title = trim( (string) ( $args['title'] ?? '' ) ); + $code = (string) ( $args['code'] ?? '' ); + + if ( '' === $title || '' === $code ) { + return array( 'success' => false, 'error' => 'Arguments "title" and "code" are required.' ); + } + + $settings = get_option( 'quads_settings', array() ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + if ( ! isset( $settings['ads'] ) || ! is_array( $settings['ads'] ) ) { + $settings['ads'] = array(); + } + + // Generate slot key if not provided + $slot_key = trim( (string) ( $args['slot_key'] ?? '' ) ); + if ( '' === $slot_key ) { + $max_num = 0; + foreach ( array_keys( $settings['ads'] ) as $k ) { + if ( preg_match( '/^ad(\d+)$/i', $k, $m ) ) { + $max_num = max( $max_num, (int) $m[1] ); + } + } + $slot_key = 'ad' . ( $max_num + 1 ); + } + + $position = $args['position'] ?? 'custom'; + $dimensions = $args['dimensions'] ?? ''; + if ( empty( $dimensions ) ) { + $parsed = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $code ); + $dimensions = $parsed['dimensions'] ?? ''; + } + + // 1. Create quads-ads post if post type exists + $post_id = 0; + if ( function_exists( 'wp_insert_post' ) ) { + $post_id = wp_insert_post( + array( + 'post_title' => $title, + 'post_type' => 'quads-ads', + 'post_status' => 'publish', + ) + ); + if ( $post_id && ! is_wp_error( $post_id ) ) { + update_post_meta( $post_id, 'quads_ad_old_id', $slot_key ); + update_post_meta( $post_id, 'code', $code ); + update_post_meta( $post_id, 'position', $position ); + if ( ! empty( $dimensions ) ) { + update_post_meta( $post_id, 'dimensions', $dimensions ); + } + } else { + $post_id = 0; + } + } + + // 2. Add to quads_settings + $slot_entry = array( + 'code' => $code, + 'label' => $title, + 'position' => $position, + 'dimensions' => $dimensions, + 'ad_type' => 'plain_text', + ); + if ( isset( $args['settings'] ) && is_array( $args['settings'] ) ) { + $slot_entry = array_merge( $slot_entry, $args['settings'] ); + } + $settings['ads'][ $slot_key ] = $slot_entry; + update_option( 'quads_settings', $settings ); + + // 3. Cache purge + if ( $args['purge_cache'] ?? true ) { + self::purge_ad_cache(); + } + + return array( + 'success' => true, + 'slot_key' => $slot_key, + 'post_id' => $post_id, + 'title' => $title, + 'position' => $position, + 'detected_network' => EMCP_Tools_Ads_Read_Operations::parse_ad_code( $code ), + 'message' => sprintf( 'Ad slot "%s" created successfully.', $slot_key ), + ); + } + + /** + * Delete an ad slot with safety confirmation guard. + * + * @param array $args + * @return array + */ + public static function delete_ad( array $args ): array { + $id = trim( (string) ( $args['id'] ?? '' ) ); + $confirm = ! empty( $args['confirm'] ); + + if ( '' === $id ) { + return array( 'success' => false, 'error' => 'Argument "id" is required.' ); + } + + if ( ! $confirm ) { + return array( + 'success' => false, + 'warning' => 'Confirmation required. Pass "confirm": true to delete this ad unit.', + 'id' => $id, + ); + } + + $settings = get_option( 'quads_settings', array() ); + $slot_key = null; + $post_id = 0; + + if ( isset( $settings['ads'][ $id ] ) ) { + $slot_key = $id; + unset( $settings['ads'][ $id ] ); + update_option( 'quads_settings', $settings ); + + if ( function_exists( 'get_posts' ) ) { + $found = get_posts( + array( + 'post_type' => 'quads-ads', + 'meta_key' => 'quads_ad_old_id', + 'meta_value' => $slot_key, + 'numberposts' => 1, + ) + ); + if ( ! empty( $found ) ) { + $post_id = $found[0]->ID; + } + } + } elseif ( is_numeric( $id ) ) { + $post_id = (int) $id; + if ( function_exists( 'get_post_meta' ) ) { + $old_id = (string) get_post_meta( $post_id, 'quads_ad_old_id', true ); + if ( ! empty( $old_id ) && isset( $settings['ads'][ $old_id ] ) ) { + $slot_key = $old_id; + unset( $settings['ads'][ $old_id ] ); + update_option( 'quads_settings', $settings ); + } + } + } + + if ( $post_id && function_exists( 'wp_delete_post' ) ) { + wp_delete_post( $post_id, true ); + } + + if ( $args['purge_cache'] ?? true ) { + self::purge_ad_cache(); + } + + return array( + 'success' => true, + 'slot_key' => $slot_key ?? $id, + 'post_id' => $post_id, + 'message' => sprintf( 'Ad slot "%s" deleted successfully.', $id ), + ); + } + + /** + * Set or append records to /ads.txt. + * + * @param array $args + * @return array + */ + public static function set_ads_txt( array $args ): array { + $current = EMCP_Tools_Ads_Read_Operations::get_ads_txt(); + $raw = $current['raw']; + + // Backup + if ( $args['backup'] ?? true ) { + update_option( + 'emcp_tools_ads_txt_backup', + array( + 'timestamp' => time(), + 'content' => $raw, + ) + ); + } + + $new_content = ''; + if ( isset( $args['content'] ) && is_string( $args['content'] ) ) { + $new_content = trim( $args['content'] ); + } elseif ( ! empty( $args['append_records'] ) && is_array( $args['append_records'] ) ) { + $existing_lines = array_filter( array_map( 'trim', explode( "\n", str_replace( "\r", '', $raw ) ) ) ); + foreach ( $args['append_records'] as $rec ) { + $rec = trim( (string) $rec ); + if ( ! empty( $rec ) && ! in_array( $rec, $existing_lines, true ) ) { + $existing_lines[] = $rec; + } + } + $new_content = implode( "\n", $existing_lines ); + } else { + return array( 'success' => false, 'error' => 'Must provide either "content" or "append_records".' ); + } + + // Syntax validation + $validation_warnings = array(); + $lines = explode( "\n", $new_content ); + foreach ( $lines as $idx => $line ) { + $t = trim( $line ); + if ( '' === $t || 0 === strpos( $t, '#' ) ) { + continue; + } + $parts = array_map( 'trim', explode( ',', $t ) ); + if ( count( $parts ) < 3 ) { + $validation_warnings[] = sprintf( 'Line %d: insufficient fields: "%s"', $idx + 1, $t ); + } elseif ( ! in_array( strtoupper( $parts[2] ), array( 'DIRECT', 'RESELLER' ), true ) ) { + $validation_warnings[] = sprintf( 'Line %d: invalid relationship "%s"', $idx + 1, $parts[2] ); + } + } + + // 1. Save to option + update_option( 'emcp_tools_ads_txt', $new_content ); + + // 2. Physical write if ABSPATH is available + $file_written = false; + if ( defined( 'ABSPATH' ) ) { + $file_path = ABSPATH . 'ads.txt'; + if ( ( file_exists( $file_path ) && is_writable( $file_path ) ) || ( ! file_exists( $file_path ) && is_writable( ABSPATH ) ) ) { + $file_written = ( false !== @file_put_contents( $file_path, $new_content ) ); + } + } + + // 3. Purge cache + if ( $args['purge_cache'] ?? true ) { + self::purge_ad_cache(); + } + + return array( + 'success' => true, + 'records_count' => count( array_filter( $lines, fn( $l ) => '' !== trim( $l ) && 0 !== strpos( trim( $l ), '#' ) ) ), + 'validation_warnings' => $validation_warnings, + 'file_written' => $file_written, + 'saved_to_option' => true, + 'message' => '/ads.txt updated successfully.', + ); + } + + /** + * Purge ad transients and page caches. + * + * @param array $args + * @return array + */ + public static function purge_ad_cache( array $args = array() ): array { + $purged = array(); + + // LiteSpeed Cache purge + if ( function_exists( 'do_action' ) ) { + do_action( 'litespeed_purge_all' ); + $purged[] = 'litespeed_purge_all'; + } + + // Object Cache + if ( function_exists( 'wp_cache_flush' ) ) { + wp_cache_flush(); + $purged[] = 'wp_cache_flush'; + } + + // Transient purge for ExoClick + if ( function_exists( 'delete_transient' ) ) { + global $wpdb; + if ( ! empty( $wpdb ) && isset( $wpdb->options ) ) { + $keys = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_emcp_exoclick_%'" ); + if ( is_array( $keys ) ) { + foreach ( $keys as $k ) { + $transient_name = str_replace( '_transient_', '', $k ); + delete_transient( $transient_name ); + } + $purged[] = 'exoclick_transients'; + } + } + } + + return array( + 'success' => true, + 'purged' => $purged, + 'message' => 'Ad caches successfully flushed.', + ); + } + + /** + * Create an ExoClick zone via REST API with optional automatic installation. + * + * @param array $args + * @return array + */ + public static function exoclick_create_zone( array $args ): array { + $name = trim( (string) ( $args['name'] ?? '' ) ); + if ( '' === $name ) { + return array( 'success' => false, 'error' => 'Argument "name" is required.' ); + } + + $token = EMCP_Tools_Ads_Read_Operations::get_exoclick_token( $args ); + if ( ! $token ) { + return array( 'success' => false, 'error' => 'ExoClick API token is required.' ); + } + + $bearer = EMCP_Tools_Ads_Read_Operations::authenticate_exoclick( $token ); + if ( is_wp_error( $bearer ) ) { + return array( 'success' => false, 'error' => $bearer->get_error_message() ); + } + + $site_id = $args['idsite'] ?? get_option( 'emcp_tools_exoclick_site_id', 1111220 ); + $dimensions = $args['dimensions'] ?? '300x250'; + $format_id = $args['idsub_type_format'] ?? 1; // 1 = Banner + + $body = array( + 'name' => $name, + 'idsite' => absint( $site_id ), + 'idsub_type_format' => absint( $format_id ), + 'description' => 'Created by Elementor MCP', + ); + + $res = wp_remote_post( + 'https://api.exoclick.com/v2/zones', + array( + 'headers' => array( + 'Authorization' => 'Bearer ' . $bearer, + 'Content-Type' => 'application/json', + 'User-Agent' => 'EMCP-Tools/WordPress', + ), + 'body' => wp_json_encode( $body ), + 'timeout' => 15, + ) + ); + + if ( is_wp_error( $res ) ) { + return array( 'success' => false, 'error' => $res->get_error_message() ); + } + + $data = json_decode( wp_remote_retrieve_body( $res ), true ); + $zone_id = $data['idzone'] ?? ( $data['result']['idzone'] ?? null ); + + if ( ! $zone_id ) { + return array( + 'success' => false, + 'error' => $data['message'] ?? 'Failed to create zone on ExoClick.', + 'raw' => $data, + ); + } + + // Generate canonical ExoClick JS banner snippet + $snippet = sprintf( + '' . "\n" . + '' . "\n" . + '', + $zone_id + ); + + $install_res = null; + $slot_target = $args['install_to_slot'] ?? ''; + if ( ! empty( $slot_target ) ) { + if ( 'create' === $slot_target ) { + $install_res = self::create_ad( + array( + 'title' => $name, + 'code' => $snippet, + 'dimensions' => $dimensions, + ) + ); + } else { + $install_res = self::update_ad( + array( + 'id' => $slot_target, + 'code' => $snippet, + 'title' => $name, + 'dimensions' => $dimensions, + ) + ); + } + } + + return array( + 'success' => true, + 'zone_id' => $zone_id, + 'site_id' => $site_id, + 'name' => $name, + 'tag_code' => $snippet, + 'installation' => $install_res, + ); + } + + /** + * Verify site with ExoClick. + * + * @param array $args + * @return array + */ + public static function exoclick_verify_site( array $args = array() ): array { + $token = EMCP_Tools_Ads_Read_Operations::get_exoclick_token( $args ); + if ( ! $token ) { + return array( 'success' => false, 'error' => 'ExoClick API token is required.' ); + } + + $bearer = EMCP_Tools_Ads_Read_Operations::authenticate_exoclick( $token ); + if ( is_wp_error( $bearer ) ) { + return array( 'success' => false, 'error' => $bearer->get_error_message() ); + } + + $site_id = $args['idsite'] ?? get_option( 'emcp_tools_exoclick_site_id', 1111220 ); + + // Step 1: Trigger verification check + $res = wp_remote_post( + 'https://api.exoclick.com/v2/sites/url-verification', + array( + 'headers' => array( + 'Authorization' => 'Bearer ' . $bearer, + 'Content-Type' => 'application/json', + 'User-Agent' => 'EMCP-Tools/WordPress', + ), + 'body' => wp_json_encode( array( 'idsite' => absint( $site_id ) ) ), + 'timeout' => 15, + ) + ); + + if ( is_wp_error( $res ) ) { + return array( 'success' => false, 'error' => $res->get_error_message() ); + } + + $data = json_decode( wp_remote_retrieve_body( $res ), true ); + return array( + 'success' => true, + 'site_id' => $site_id, + 'result' => $data, + ); + } +} diff --git a/includes/abilities/class-ability-registrar.php b/includes/abilities/class-ability-registrar.php index 6992b21..0ca3dc1 100644 --- a/includes/abilities/class-ability-registrar.php +++ b/includes/abilities/class-ability-registrar.php @@ -282,6 +282,13 @@ class EMCP_Tools_Ability_Registrar { $this->ability_names = array_merge( $this->ability_names, $comic_easel->get_ability_names() ); } + // Ads & Monetization abilities (WP Quads, dynamic ads.txt, ExoClick). + if ( class_exists( 'EMCP_Tools_Ads_Integration' ) && EMCP_Tools_Ads_Integration::is_active() ) { + $ads_integration = new EMCP_Tools_Ads_Integration(); + $ads_integration->register(); + $this->ability_names = array_merge( $this->ability_names, $ads_integration->get_ability_names() ); + } + // Forms-tab integrations. CF7 is free; the five entry-storing plugins are // Pro. Each registers only when its plugin is active (is_available()). $form_integrations = array(); diff --git a/includes/class-pro-loader.php b/includes/class-pro-loader.php index 91b8a0f..f155876 100644 --- a/includes/class-pro-loader.php +++ b/includes/class-pro-loader.php @@ -95,6 +95,9 @@ final class EMCP_Tools_Pro_Loader { 'includes/abilities/comic/class-comic-read-operations.php', 'includes/abilities/comic/class-comic-write-operations.php', 'includes/abilities/comic/class-comic-easel-integration.php', + 'includes/abilities/ads/class-ads-read-operations.php', + 'includes/abilities/ads/class-ads-write-operations.php', + 'includes/abilities/ads/class-ads-integration.php', 'includes/abilities/forms/class-wpforms-integration.php', 'includes/abilities/forms/class-gravityforms-integration.php', 'includes/abilities/forms/class-fluentforms-integration.php', diff --git a/skills/emcp-ads/SKILL.md b/skills/emcp-ads/SKILL.md new file mode 100644 index 0000000..a1ef289 --- /dev/null +++ b/skills/emcp-ads/SKILL.md @@ -0,0 +1,131 @@ +--- +name: "Ad Monetization & ExoClick Manager" +description: "Manage WP Quads ad slots, dynamic ads.txt, ExoClick REST API zones, reporting, responsive ad styling, and cache invalidation" +--- +# Ad Monetization & ExoClick Manager + +Teach AI agents how to manage, audit, and optimize website advertising inventory, WP Quads ad units, dynamic `/ads.txt` records, and ExoClick REST API integrations across WordPress. + +## Architecture & Data Flow + +### 1. WP Quads Dual-Write Synchronization +WP Quads operates with a split data architecture: +- **Custom Post Type**: `quads-ads` posts store `code`, `position`, `dimensions`, and `quads_ad_old_id` (e.g. `ad1`, `ad2`) in `wp_postmeta`. +- **Serialized Settings**: `quads_settings` option in `wp_options` stores an `ads` associative array keyed by slot ID (`ad1`, `ad2`, ...). +- **Critical Rule**: Updating only one side leads to cache desynchronization, stale banner codes, or blank slots on frontend pages. Always use `emcp-tools/ads-write` (`update-ad` / `create-ad`), which enforces simultaneous dual-writes to both postmeta and serialized options. + +### 2. Standard Responsive Placements & Dimensions +When placing ads in comic or blog layouts, maintain strict layout integrity: +- **Above Comic**: `728x90` Leaderboard. Wrap in responsive container centered above the primary canvas. +- **Under Comic**: `300x250` Medium Rectangle. Placed immediately after navigation or author notes. +- **Sidebars (Left & Right)**: `160x600` Wide Skyscrapers. Anchored in layout rails without overlapping main comic panels. +- **Sticky Mobile Footer**: Anchored floating banner (`eas6a97888e10` or `300x100`/`300x50`) hidden on desktop breakpoints. +- **Popunder (ExoClick PopMagic)**: `idzone: 6020536`. Always enforce frequency capping (minimum 24-hour cookie cooldown) to preserve user retention. + +### 3. IAB ads.txt Management +- **Syntax**: `, , , ` + - Example: `exoclick.com, 1111220, DIRECT, f6e6255c27770857` + - Relationship must strictly be `DIRECT` or `RESELLER`. +- **Dual Persistence**: The plugin writes to `ABSPATH . 'ads.txt'` if filesystem permissions permit, and always synchronizes with the WordPress database option `emcp_tools_ads_txt` to ensure uninterrupted serving across all hosting configurations. + +### 4. Cache Purging Protocol +Whenever ad tags, zone IDs, or `/ads.txt` are modified: +1. Purge LiteSpeed page cache: `do_action('litespeed_purge_all')` +2. Flush WordPress object cache: `wp_cache_flush()` +3. Flush transient API tokens: `purge-ad-cache` + +--- + +## MCP Tools Reference + +### `emcp-tools/ads-read` +Call with no `operation` to inspect full operation schemas and argument hints. + +- `list-ads`: List all registered ad units from WP Quads, including detected ad networks (`ExoClick`, `JuicyAds`, `TrafficStars`), slot keys, dimensions, and active statuses. + ```json + { "operation": "list-ads", "arguments": { "status": "all" } } + ``` +- `get-ad`: Get complete configuration, dimensions, code, and placement for a specific ad slot by Post ID or Quads key. + ```json + { "operation": "get-ad", "arguments": { "id": "ad1" } } + ``` +- `get-ads-txt`: Parse `/ads.txt` records, validate IAB formatting, and inspect authorized digital sellers. + ```json + { "operation": "get-ads-txt", "arguments": { "validate_syntax": true } } + ``` +- `audit-monetization`: Diagnostic scan checking ads.txt health, active unit count, multi-network script conflicts, and responsive styling. + ```json + { "operation": "audit-monetization", "arguments": {} } + ``` +- `exoclick-list-zones`: Query ExoClick REST API for registered zones, IDs, types, and statuses. + ```json + { "operation": "exoclick-list-zones", "arguments": { "idsite": 1111220 } } + ``` +- `exoclick-get-stats`: Query ExoClick reporting API for impressions, clicks, CTR, eCPM, and revenue metrics. + ```json + { "operation": "exoclick-get-stats", "arguments": { "date_from": "2026-09-01", "date_to": "2026-09-05" } } + ``` + +--- + +### `emcp-tools/ads-write` +Call with no `operation` to inspect full schemas. + +- `update-ad`: Dual-write code, title, or position to WP Quads postmeta and `quads_settings`, automatically purging page cache. + ```json + { + "operation": "update-ad", + "arguments": { + "id": "ad1", + "code": "", + "title": "Above Comic 728x90", + "purge_cache": true + } + } + ``` +- `create-ad`: Create a new ad unit across both posts and serialized settings. + ```json + { + "operation": "create-ad", + "arguments": { + "title": "Sticky Mobile Footer", + "code": "", + "slot_key": "ad11", + "position": "custom" + } + } + ``` +- `delete-ad`: Delete ad unit from WP Quads and settings (`confirm: true` required for safety). + ```json + { "operation": "delete-ad", "arguments": { "id": "ad11", "confirm": true } } + ``` +- `set-ads-txt`: Replace or append lines to `/ads.txt` with IAB syntax pre-validation. + ```json + { + "operation": "set-ads-txt", + "arguments": { + "append_records": [ + "exoclick.com, 1111220, DIRECT, f6e6255c27770857" + ] + } + } + ``` +- `purge-ad-cache`: Clear LiteSpeed page cache, WP object cache, and temporary transients. + ```json + { "operation": "purge-ad-cache", "arguments": { "all": true } } + ``` +- `exoclick-create-zone`: Create a new ad zone via ExoClick API and optionally wire it into a WP Quads slot. + ```json + { + "operation": "exoclick-create-zone", + "arguments": { + "name": "Under Comic 300x250", + "dimensions": "300x250", + "install_to_slot": "ad2" + } + } + ``` +- `exoclick-verify-site`: Verify site ownership with ExoClick via API verification check. + ```json + { "operation": "exoclick-verify-site", "arguments": { "idsite": 1111220 } } + ``` diff --git a/tests/test-ads-mcp.php b/tests/test-ads-mcp.php new file mode 100644 index 0000000..0469d3b --- /dev/null +++ b/tests/test-ads-mcp.php @@ -0,0 +1,463 @@ +code = $c; $this->message = $m; } + public function get_error_message() { return $this->message; } + public function get_error_code() { return $this->code; } +} +function is_wp_error( $t ) { return $t instanceof WP_Error; } + +function get_post( $id ) { + return $GLOBALS['wp_posts'][ $id ] ?? null; +} + +function get_the_title( $id ) { + return $GLOBALS['wp_posts'][ $id ]->post_title ?? ''; +} + +function get_posts( $args = array() ) { + $results = array(); + $type = $args['post_type'] ?? 'post'; + $meta_k = $args['meta_key'] ?? ''; + $meta_v = $args['meta_value'] ?? ''; + + foreach ( $GLOBALS['wp_posts'] as $p ) { + if ( $type && $p->post_type !== $type ) continue; + if ( $meta_k ) { + $val = get_post_meta( $p->ID, $meta_k, true ); + if ( $val != $meta_v ) continue; + } + $results[] = $p; + } + return $results; +} + +function wp_insert_post( $args, $wp_error = false ) { + $id = ++$GLOBALS['wp_post_counter']; + $p = new WP_Post(); + $p->ID = $id; + $p->post_title = $args['post_title'] ?? ''; + $p->post_name = sanitize_title( $p->post_title ); + $p->post_type = $args['post_type'] ?? 'quads-ads'; + $p->post_status = $args['post_status'] ?? 'publish'; + $GLOBALS['wp_posts'][ $id ] = $p; + return $id; +} + +function wp_update_post( $args ) { + $id = $args['ID'] ?? 0; + if ( ! isset( $GLOBALS['wp_posts'][ $id ] ) ) return false; + $p = $GLOBALS['wp_posts'][ $id ]; + if ( isset( $args['post_title'] ) ) { + $p->post_title = $args['post_title']; + $p->post_name = sanitize_title( $p->post_title ); + } + return $id; +} + +function wp_delete_post( $id, $force = false ) { + if ( isset( $GLOBALS['wp_posts'][ $id ] ) ) { + unset( $GLOBALS['wp_posts'][ $id ] ); + unset( $GLOBALS['wp_postmeta'][ $id ] ); + return true; + } + return false; +} + +function get_post_meta( $post_id, $key = '', $single = false ) { + if ( $key ) { + return $GLOBALS['wp_postmeta'][ $post_id ][ $key ] ?? ''; + } + return $GLOBALS['wp_postmeta'][ $post_id ] ?? array(); +} + +function update_post_meta( $post_id, $key, $val ) { + $GLOBALS['wp_postmeta'][ $post_id ][ $key ] = $val; + return true; +} + +// HTTP Mocking +function wp_remote_retrieve_body( $resp ) { + return is_array( $resp ) ? ( $resp['body'] ?? '' ) : ''; +} +function wp_remote_retrieve_response_code( $resp ) { + return is_array( $resp ) ? ( $resp['response']['code'] ?? 200 ) : 200; +} + +function wp_remote_post( $url, $args = array() ) { + // Mock login endpoint + if ( strpos( $url, 'api.exoclick.com/v2/login' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'token' => 'mock_bearer_token_xyz_123', + 'expires_in' => 3600, + ) ), + ); + } + // Mock zone creation + if ( strpos( $url, 'api.exoclick.com/v2/zones' ) !== false ) { + $data = json_decode( $args['body'] ?? '{}', true ); + return array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'idzone' => 6020599, + 'name' => $data['name'] ?? 'Test Zone', + 'idsite' => $data['idsite'] ?? 1111220, + ) ), + ); + } + // Mock URL verification + if ( strpos( $url, 'api.exoclick.com/v2/sites/url-verification' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'success' => true, + 'status' => 1, + 'message' => 'Site ownership successfully verified.', + ) ), + ); + } + return array( 'response' => array( 'code' => 200 ), 'body' => '{}' ); +} + +function wp_remote_get( $url, $args = array() ) { + // Mock zones list + if ( strpos( $url, 'api.exoclick.com/v2/zones' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'result' => array( + array( 'idzone' => 6020542, 'name' => 'Above Comic - 728x90', 'status' => 1 ), + array( 'idzone' => 6020532, 'name' => 'Under Comic - 300x250', 'status' => 1 ), + ), + ) ), + ); + } + // Mock reporting stats + if ( strpos( $url, 'api.exoclick.com/v2/statistics' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'result' => array( + array( 'impressions' => 150240, 'clicks' => 312, 'ctr' => 0.21, 'revenue' => 45.80 ), + ), + ) ), + ); + } + // Mock live ads.txt fetch + if ( strpos( $url, '/ads.txt' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => "exoclick.com, 1111220, DIRECT, f6e6255c27770857\njuicyads.com, 119864, DIRECT\n", + ); + } + return array( 'response' => array( 'code' => 200 ), 'body' => '' ); +} + +function emcp_tools_register_ability( $name, $conf ) { + $GLOBALS['abilities'][ $name ] = $conf; +} + +// Load classes under test +require_once __DIR__ . '/../includes/abilities/ads/class-ads-read-operations.php'; +require_once __DIR__ . '/../includes/abilities/ads/class-ads-write-operations.php'; +require_once __DIR__ . '/../includes/abilities/ads/class-ads-integration.php'; + +echo "=== TEST 1: Ad Network Detection & Parsing (parse_ad_code) ===\n"; +// 1. ExoClick Banner +$exo_tag = '' . + '' . + ''; +$parsed_exo = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $exo_tag ); +assert( $parsed_exo['network'] === 'ExoClick' ); +assert( $parsed_exo['zone_id'] === '6020542' ); +assert( $parsed_exo['dimensions'] === '728x90' ); + +// 2. ExoClick Popunder +$exo_pop = ''; +$parsed_pop = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $exo_pop ); +assert( $parsed_pop['network'] === 'ExoClick' ); +assert( $parsed_pop['zone_id'] === '6020536' ); + +// 3. JuicyAds Legacy +$juicy_tag = '' . + ''; +$parsed_juicy = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $juicy_tag ); +assert( $parsed_juicy['network'] === 'JuicyAds' ); +assert( $parsed_juicy['zone_id'] === '1069796' ); +assert( $parsed_juicy['dimensions'] === '160x600' ); + +// 4. TrafficStars +$ts_tag = ''; +$parsed_ts = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $ts_tag ); +assert( $parsed_ts['network'] === 'TrafficStars' ); + +echo "PASS: Ad network detection and dimension parsing verified!\n\n"; + +echo "=== TEST 2: Schema Completeness & Discovery Catalog ===\n"; +$read_schema = EMCP_Tools_Ads_Read_Operations::op_schema(); +$write_schema = EMCP_Tools_Ads_Write_Operations::op_schema(); + +assert( count( $read_schema ) === 6 ); +assert( isset( $read_schema['list-ads'], $read_schema['get-ad'], $read_schema['get-ads-txt'], $read_schema['audit-monetization'], $read_schema['exoclick-list-zones'], $read_schema['exoclick-get-stats'] ) ); + +assert( count( $write_schema ) === 7 ); +assert( isset( $write_schema['update-ad'], $write_schema['create-ad'], $write_schema['delete-ad'], $write_schema['set-ads-txt'], $write_schema['purge-ad-cache'], $write_schema['exoclick-create-zone'], $write_schema['exoclick-verify-site'] ) ); + +echo "PASS: All 13 read & write operation schemas verified!\n\n"; + +echo "=== TEST 3: WP Quads Create, Update & Dual-Write Synchronization ===\n"; +// 1. Create ad slot +$create_res = EMCP_Tools_Ads_Write_Operations::create_ad( array( + 'title' => 'Above Comic 728x90', + 'code' => $exo_tag, + 'slot_key' => 'ad1', + 'position' => 'custom', + 'dimensions' => '728x90', +) ); + +assert( $create_res['success'] === true ); +assert( $create_res['slot_key'] === 'ad1' ); +$post_id = $create_res['post_id']; +assert( $post_id > 0 ); + +// Verify quads_settings option +$settings = get_option( 'quads_settings' ); +assert( isset( $settings['ads']['ad1'] ) ); +assert( $settings['ads']['ad1']['code'] === $exo_tag ); +assert( $settings['ads']['ad1']['label'] === 'Above Comic 728x90' ); + +// Verify postmeta +assert( get_post_meta( $post_id, 'code', true ) === $exo_tag ); +assert( get_post_meta( $post_id, 'quads_ad_old_id', true ) === 'ad1' ); + +// 2. Read ad slot +$read_res = EMCP_Tools_Ads_Read_Operations::get_ad( array( 'id' => 'ad1' ) ); +assert( $read_res['success'] === true ); +assert( $read_res['slot_key'] === 'ad1' ); +assert( $read_res['parsed']['network'] === 'ExoClick' ); +assert( $read_res['parsed']['zone_id'] === '6020542' ); + +// 3. Update ad slot +$updated_exo_tag = str_replace( '6020542', '6020599', $exo_tag ); +$update_res = EMCP_Tools_Ads_Write_Operations::update_ad( array( + 'id' => 'ad1', + 'code' => $updated_exo_tag, + 'title' => 'Above Comic - ExoClick 6020599', +) ); + +assert( $update_res['success'] === true ); +// Verify dual-write sync after update +$settings = get_option( 'quads_settings' ); +assert( $settings['ads']['ad1']['code'] === $updated_exo_tag ); +assert( $settings['ads']['ad1']['label'] === 'Above Comic - ExoClick 6020599' ); +assert( get_post_meta( $post_id, 'code', true ) === $updated_exo_tag ); + +// 4. List ads +$list_res = EMCP_Tools_Ads_Read_Operations::list_ads(); +assert( $list_res['success'] === true ); +assert( $list_res['total_ads'] === 1 ); +assert( $list_res['ads'][0]['zone_id'] === '6020599' ); + +echo "PASS: Create, Update, and Dual-Write synchronization verified!\n\n"; + +echo "=== TEST 4: Delete Ad Unit with Confirmation Guard ===\n"; +// Attempt delete without confirm: true +$unconfirmed = EMCP_Tools_Ads_Write_Operations::delete_ad( array( 'id' => 'ad1' ) ); +assert( $unconfirmed['success'] === false ); +assert( strpos( $unconfirmed['warning'], 'Confirmation required' ) !== false ); +assert( isset( $GLOBALS['wp_posts'][ $post_id ] ) ); // Still exists + +// Delete with confirm: true +$confirmed = EMCP_Tools_Ads_Write_Operations::delete_ad( array( 'id' => 'ad1', 'confirm' => true ) ); +assert( $confirmed['success'] === true ); +assert( ! isset( $GLOBALS['wp_posts'][ $post_id ] ) ); // Deleted from posts +$settings = get_option( 'quads_settings' ); +assert( ! isset( $settings['ads']['ad1'] ) ); // Deleted from quads_settings + +echo "PASS: Safety confirmation guard and complete ad unit deletion verified!\n\n"; + +echo "=== TEST 5: ads.txt Parser, Syntax Validator & Dual-Write ===\n"; +// 1. Set full ads.txt content +$sample_ads_txt = "# Authorized Digital Sellers for shad-base.com\n" . + "exoclick.com, 1111220, DIRECT, f6e6255c27770857\n" . + "trafficstars.com, 48912, DIRECT\n" . + "badline_format\n" . + "invalidrel.com, 99999, PARTNER\n"; + +$set_res = EMCP_Tools_Ads_Write_Operations::set_ads_txt( array( + 'content' => $sample_ads_txt, +) ); +assert( $set_res['success'] === true ); +assert( $set_res['saved_to_option'] === true ); +assert( count( $set_res['validation_warnings'] ) === 2 ); + +// 2. Read and parse ads.txt +$parsed_txt = EMCP_Tools_Ads_Read_Operations::get_ads_txt(); +assert( $parsed_txt['success'] === true ); +assert( $parsed_txt['total_records'] === 3 ); +assert( $parsed_txt['records'][0]['domain'] === 'exoclick.com' ); +assert( $parsed_txt['records'][0]['publisher_id'] === '1111220' ); +assert( $parsed_txt['records'][0]['relationship'] === 'DIRECT' ); +assert( $parsed_txt['records'][0]['certification_authority_id'] === 'f6e6255c27770857' ); +assert( $parsed_txt['records'][0]['is_valid'] === true ); + +// 3. Append record +$append_res = EMCP_Tools_Ads_Write_Operations::set_ads_txt( array( + 'append_records' => array( 'juicyads.com, 119864, DIRECT' ), +) ); +assert( $append_res['success'] === true ); +$re_parsed = EMCP_Tools_Ads_Read_Operations::get_ads_txt(); +assert( strpos( $re_parsed['raw'], 'juicyads.com, 119864, DIRECT' ) !== false ); + +echo "PASS: ads.txt parser, syntax validation, backup, and append verified!\n\n"; + +echo "=== TEST 6: Monetization Diagnostic Audit ===\n"; +// With only ads.txt and no ads +$audit1 = EMCP_Tools_Ads_Read_Operations::audit_monetization(); +assert( $audit1['success'] === true ); +assert( $audit1['status'] === 'attention_required' ); +assert( in_array( 'No active ad units found in WP Quads.', $audit1['issues'], true ) ); + +// Add active ExoClick ad unit +EMCP_Tools_Ads_Write_Operations::create_ad( array( + 'title' => 'Main Banner', + 'code' => $exo_tag, +) ); +$audit2 = EMCP_Tools_Ads_Read_Operations::audit_monetization(); +assert( $audit2['success'] === true ); +assert( $audit2['active_ad_units'] === 1 ); + +echo "PASS: Monetization diagnostic audit verified!\n\n"; + +echo "=== TEST 7: Cache Purging (LiteSpeed & Object Cache) ===\n"; +$GLOBALS['actions_fired'] = array(); +$purge_res = EMCP_Tools_Ads_Write_Operations::purge_ad_cache(); +assert( $purge_res['success'] === true ); +assert( in_array( 'litespeed_purge_all', $GLOBALS['actions_fired'], true ) ); +assert( in_array( 'wp_cache_flush', $GLOBALS['actions_fired'], true ) ); + +echo "PASS: Cache purging operations verified!\n\n"; + +echo "=== TEST 8: ExoClick REST API Integration Mock ===\n"; +// Set API token +update_option( 'emcp_tools_exoclick_api_token', 'test_api_token_12345' ); +update_option( 'emcp_tools_exoclick_site_id', 1111220 ); + +// 1. Zones list +$zones_res = EMCP_Tools_Ads_Read_Operations::exoclick_list_zones(); +assert( $zones_res['success'] === true ); +assert( count( $zones_res['zones'] ) === 2 ); + +// 2. Reporting stats +$stats_res = EMCP_Tools_Ads_Read_Operations::exoclick_get_stats(); +assert( $stats_res['success'] === true ); +assert( $stats_res['stats'][0]['impressions'] === 150240 ); + +// 3. Create Zone with automatic slot installation +$zone_create = EMCP_Tools_Ads_Write_Operations::exoclick_create_zone( array( + 'name' => 'Sidebar Ad', + 'dimensions' => '160x600', + 'install_to_slot' => 'create', +) ); +assert( $zone_create['success'] === true ); +assert( $zone_create['zone_id'] === 6020599 ); +assert( strpos( $zone_create['tag_code'], 'data-zoneid="6020599"' ) !== false ); +assert( $zone_create['installation']['success'] === true ); + +// 4. Site Verification +$verify_res = EMCP_Tools_Ads_Write_Operations::exoclick_verify_site(); +assert( $verify_res['success'] === true ); +assert( $verify_res['result']['status'] === 1 ); + +echo "PASS: ExoClick REST API zones, stats, zone creation, and site verification verified!\n\n"; + +echo "=== TEST 9: Abilities API Dispatcher Tools (emcp-tools/ads-read & ads-write) ===\n"; +$integration = new EMCP_Tools_Ads_Integration(); +$integration->register(); + +assert( isset( $GLOBALS['abilities']['emcp-tools/ads-read'] ) ); +assert( isset( $GLOBALS['abilities']['emcp-tools/ads-write'] ) ); + +// 1. Catalog discovery call (empty operation) +$read_catalog = $integration->execute_read( array() ); +assert( isset( $read_catalog['tool'], $read_catalog['operations'] ) ); +assert( count( $read_catalog['operations'] ) === 6 ); +assert( isset( $read_catalog['operations']['list-ads']['schema'] ) ); + +$write_catalog = $integration->execute_write( array() ); +assert( isset( $write_catalog['tool'], $write_catalog['operations'] ) ); +assert( count( $write_catalog['operations'] ) === 7 ); + +// 2. Dispatcher execution +$dispatched_read = $integration->execute_read( array( + 'operation' => 'get-ads-txt', + 'arguments' => array(), +) ); +assert( $dispatched_read['success'] === true ); +assert( $dispatched_read['total_records'] > 0 ); + +$dispatched_write = $integration->execute_write( array( + 'operation' => 'purge-ad-cache', + 'arguments' => array( 'all' => true ), +) ); +assert( $dispatched_write['success'] === true ); + +echo "PASS: Abilities API registration, catalog introspection, and dispatchers verified!\n\n"; + +echo "ALL 9 ADS & MONETIZATION MCP TEST SUITES PASSED 100%!\n"; + +if ( file_exists( ABSPATH . "ads.txt" ) ) { + @unlink( ABSPATH . "ads.txt" ); +}