developer_token = self::get_setting( 'google_ads_developer_token' ); $this -> client_id = self::get_setting( 'google_ads_client_id' ); $this -> client_secret = self::get_setting( 'google_ads_client_secret' ); $this -> refresh_token = self::get_setting( 'google_ads_refresh_token' ); $this -> manager_account_id = self::get_setting( 'google_ads_manager_account_id' ); } // --- Settings CRUD --- public static function get_setting( $key ) { global $mdb; return $mdb -> get( 'settings', 'setting_value', [ 'setting_key' => $key ] ); } public static function set_setting( $key, $value ) { global $mdb; if ( $mdb -> count( 'settings', [ 'setting_key' => $key ] ) ) { $mdb -> update( 'settings', [ 'setting_value' => $value ], [ 'setting_key' => $key ] ); } else { $mdb -> insert( 'settings', [ 'setting_key' => $key, 'setting_value' => $value ] ); } } // --- Konfiguracja --- public function is_configured() { return !empty( $this -> developer_token ) && !empty( $this -> client_id ) && !empty( $this -> client_secret ) && !empty( $this -> refresh_token ); } // --- OAuth2 --- private function get_access_token() { $cached_token = self::get_setting( 'google_ads_access_token' ); $cached_expires = (int) self::get_setting( 'google_ads_access_token_expires' ); if ( $cached_token && $cached_expires > time() ) { $this -> access_token = $cached_token; return $this -> access_token; } return $this -> refresh_access_token(); } private function refresh_access_token() { $ch = curl_init( self::$TOKEN_URL ); curl_setopt_array( $ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query( [ 'client_id' => $this -> client_id, 'client_secret' => $this -> client_secret, 'refresh_token' => $this -> refresh_token, 'grant_type' => 'refresh_token' ] ), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_TIMEOUT => 30, ] ); $response = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); $error = curl_error( $ch ); curl_close( $ch ); if ( $http_code !== 200 || !$response ) { self::set_setting( 'google_ads_last_error', 'Token refresh failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . $response ); return false; } $data = json_decode( $response, true ); if ( !isset( $data['access_token'] ) ) { self::set_setting( 'google_ads_last_error', 'Token refresh: brak access_token w odpowiedzi' ); return false; } $this -> access_token = $data['access_token']; $expires_at = time() + ( $data['expires_in'] ?? 3600 ) - 60; self::set_setting( 'google_ads_access_token', $this -> access_token ); self::set_setting( 'google_ads_access_token_expires', $expires_at ); self::set_setting( 'google_ads_last_error', null ); return $this -> access_token; } // --- Google Ads API --- public function search_stream( $customer_id, $gaql_query ) { $access_token = $this -> get_access_token(); if ( !$access_token ) return false; $customer_id = str_replace( '-', '', $customer_id ); $url = self::$ADS_BASE_URL . '/' . self::$API_VERSION . '/customers/' . $customer_id . '/googleAds:searchStream'; $headers = [ 'Authorization: Bearer ' . $access_token, 'developer-token: ' . $this -> developer_token, 'Content-Type: application/json', ]; if ( !empty( $this -> manager_account_id ) ) { $headers[] = 'login-customer-id: ' . str_replace( '-', '', $this -> manager_account_id ); } $ch = curl_init( $url ); curl_setopt_array( $ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => json_encode( [ 'query' => $gaql_query ] ), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_TIMEOUT => 120, ] ); $response = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); $error = curl_error( $ch ); curl_close( $ch ); if ( $http_code !== 200 || !$response ) { self::set_setting( 'google_ads_last_error', 'searchStream failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( $response, 0, 500 ) ); return false; } $data = json_decode( $response, true ); // searchStream zwraca tablicę batch'y, każdy z kluczem 'results' $results = []; if ( is_array( $data ) ) { foreach ( $data as $batch ) { if ( isset( $batch['results'] ) && is_array( $batch['results'] ) ) { $results = array_merge( $results, $batch['results'] ); } } } self::set_setting( 'google_ads_last_error', null ); return $results; } public function mutate( $customer_id, $mutate_operations, $partial_failure = false ) { $access_token = $this -> get_access_token(); if ( !$access_token ) return false; $customer_id = str_replace( '-', '', $customer_id ); $url = self::$ADS_BASE_URL . '/' . self::$API_VERSION . '/customers/' . $customer_id . '/googleAds:mutate'; $headers = [ 'Authorization: Bearer ' . $access_token, 'developer-token: ' . $this -> developer_token, 'Content-Type: application/json', ]; if ( !empty( $this -> manager_account_id ) ) { $headers[] = 'login-customer-id: ' . str_replace( '-', '', $this -> manager_account_id ); } $payload = [ 'mutateOperations' => array_values( $mutate_operations ), 'partialFailure' => (bool) $partial_failure ]; $ch = curl_init( $url ); curl_setopt_array( $ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => json_encode( $payload ), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_TIMEOUT => 120, ] ); $response = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); $error = curl_error( $ch ); curl_close( $ch ); if ( $http_code !== 200 || !$response ) { self::set_setting( 'google_ads_last_error', 'mutate failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) ); return false; } $data = json_decode( $response, true ); if ( !is_array( $data ) ) { self::set_setting( 'google_ads_last_error', 'mutate failed: niepoprawna odpowiedz JSON' ); return false; } self::set_setting( 'google_ads_last_error', null ); return $data; } public function add_negative_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'PHRASE' ) { $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); $ad_group_id = trim( (string) $ad_group_id ); $keyword_text = trim( (string) $keyword_text ); $match_type = strtoupper( trim( (string) $match_type ) ); if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' ) { self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do dodania frazy wykluczajacej.' ); return [ 'success' => false, 'duplicate' => false ]; } if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) ) { $match_type = 'PHRASE'; } $operation = [ 'adGroupCriterionOperation' => [ 'create' => [ 'adGroup' => 'customers/' . $customer_id . '/adGroups/' . $ad_group_id, 'negative' => true, 'keyword' => [ 'text' => $keyword_text, 'matchType' => $match_type ] ] ] ]; $result = $this -> mutate( $customer_id, [ $operation ] ); if ( $result === false ) { $last_error = (string) self::get_setting( 'google_ads_last_error' ); $is_duplicate = stripos( $last_error, 'DUPLICATE' ) !== false || stripos( $last_error, 'already exists' ) !== false; if ( $is_duplicate ) { return [ 'success' => true, 'duplicate' => true, 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'ad_group', $keyword_text, $match_type, null, $ad_group_id ) ]; } return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ]; } return [ 'success' => true, 'duplicate' => false, 'response' => $result, 'sent_operation' => $operation, 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'ad_group', $keyword_text, $match_type, null, $ad_group_id ) ]; } public function add_negative_keyword_to_campaign( $customer_id, $campaign_id, $keyword_text, $match_type = 'PHRASE' ) { $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); $campaign_id = trim( (string) $campaign_id ); $keyword_text = trim( (string) $keyword_text ); $match_type = strtoupper( trim( (string) $match_type ) ); if ( $customer_id === '' || $campaign_id === '' || $keyword_text === '' ) { self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do dodania frazy wykluczajacej.' ); return [ 'success' => false, 'duplicate' => false ]; } if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) ) { $match_type = 'PHRASE'; } $operation = [ 'campaignCriterionOperation' => [ 'create' => [ 'campaign' => 'customers/' . $customer_id . '/campaigns/' . $campaign_id, 'negative' => true, 'keyword' => [ 'text' => $keyword_text, 'matchType' => $match_type ] ] ] ]; $result = $this -> mutate( $customer_id, [ $operation ] ); if ( $result === false ) { $last_error = (string) self::get_setting( 'google_ads_last_error' ); $is_duplicate = stripos( $last_error, 'DUPLICATE' ) !== false || stripos( $last_error, 'already exists' ) !== false; if ( $is_duplicate ) { return [ 'success' => true, 'duplicate' => true, 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'campaign', $keyword_text, $match_type, $campaign_id, null ) ]; } return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ]; } return [ 'success' => true, 'duplicate' => false, 'response' => $result, 'sent_operation' => $operation, 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'campaign', $keyword_text, $match_type, $campaign_id, null ) ]; } private function gaql_escape( $value ) { return str_replace( [ '\\', '\'' ], [ '\\\\', '\\\'' ], (string) $value ); } private function verify_negative_keyword_exists( $customer_id, $scope, $keyword_text, $match_type, $campaign_id = null, $ad_group_id = null ) { $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); $scope = $scope === 'campaign' ? 'campaign' : 'ad_group'; $match_type = strtoupper( trim( (string) $match_type ) ); $keyword_text_escaped = $this -> gaql_escape( trim( (string) $keyword_text ) ); if ( $scope === 'campaign' ) { $campaign_id = trim( (string) $campaign_id ); if ( $campaign_id === '' || $keyword_text_escaped === '' ) { return [ 'found' => false, 'scope' => $scope, 'rows' => [], 'error' => 'Brak danych do weryfikacji.' ]; } $gaql = "SELECT " . "campaign_criterion.resource_name, " . "campaign.id, " . "campaign_criterion.keyword.text, " . "campaign_criterion.keyword.match_type " . "FROM campaign_criterion " . "WHERE campaign.id = " . $campaign_id . " " . "AND campaign_criterion.type = 'KEYWORD' " . "AND campaign_criterion.negative = TRUE " . "AND campaign_criterion.keyword.text = '" . $keyword_text_escaped . "' " . "AND campaign_criterion.keyword.match_type = " . $match_type . " " . "LIMIT 5"; } else { $ad_group_id = trim( (string) $ad_group_id ); if ( $ad_group_id === '' || $keyword_text_escaped === '' ) { return [ 'found' => false, 'scope' => $scope, 'rows' => [], 'error' => 'Brak danych do weryfikacji.' ]; } $gaql = "SELECT " . "ad_group_criterion.resource_name, " . "campaign.id, " . "ad_group.id, " . "ad_group_criterion.keyword.text, " . "ad_group_criterion.keyword.match_type " . "FROM ad_group_criterion " . "WHERE ad_group.id = " . $ad_group_id . " " . "AND ad_group_criterion.type = 'KEYWORD' " . "AND ad_group_criterion.negative = TRUE " . "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' " . "AND ad_group_criterion.keyword.match_type = " . $match_type . " " . "LIMIT 5"; } $rows = []; $last_error = null; for ( $i = 0; $i < 3; $i++ ) { $result = $this -> search_stream( $customer_id, $gaql ); if ( is_array( $result ) ) { $rows = $result; if ( count( $rows ) > 0 ) { return [ 'found' => true, 'scope' => $scope, 'rows' => $rows ]; } } else { $last_error = (string) self::get_setting( 'google_ads_last_error' ); } usleep( 400000 ); } return [ 'found' => count( $rows ) > 0, 'scope' => $scope, 'rows' => $rows, 'error' => $last_error ]; } // --- Kampanie: dane 30-dniowe --- public function get_products_for_date( $customer_id, $date ) { $date = date( 'Y-m-d', strtotime( $date ) ); $gaql = "SELECT " . "segments.date, " . "segments.product_item_id, " . "segments.product_title, " . "metrics.impressions, " . "metrics.clicks, " . "metrics.cost_micros, " . "metrics.conversions, " . "metrics.conversions_value " . "FROM shopping_performance_view " . "WHERE segments.date = '" . $date . "'"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; $products = []; foreach ( $results as $row ) { $offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) ); if ( $offer_id === '' ) { continue; } if ( !isset( $products[ $offer_id ] ) ) { $products[ $offer_id ] = [ 'OfferId' => $offer_id, 'ProductTitle' => (string) ( $row['segments']['productTitle'] ?? $offer_id ), 'Impressions' => 0, 'Clicks' => 0, 'Cost' => 0.0, 'Conversions' => 0.0, 'ConversionValue' => 0.0 ]; } $products[ $offer_id ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); $products[ $offer_id ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); $products[ $offer_id ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; $products[ $offer_id ]['Conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); $products[ $offer_id ]['ConversionValue'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); } return array_values( $products ); } public function get_campaigns_30_days( $customer_id ) { $gaql = "SELECT " . "campaign.id, " . "campaign.name, " . "campaign.bidding_strategy_type, " . "campaign.target_roas.target_roas, " . "campaign_budget.amount_micros, " . "metrics.cost_micros, " . "metrics.conversions_value " . "FROM campaign " . "WHERE campaign.status = 'ENABLED' " . "AND segments.date DURING LAST_30_DAYS"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; // Agregacja po campaign.id (API zwraca wiersz per dzień per kampania) $campaigns = []; foreach ( $results as $row ) { $cid = $row['campaign']['id'] ?? null; if ( !$cid ) continue; if ( !isset( $campaigns[ $cid ] ) ) { $campaigns[ $cid ] = [ 'campaign_id' => $cid, 'campaign_name' => $row['campaign']['name'] ?? '', 'bidding_strategy' => $row['campaign']['biddingStrategyType'] ?? 'UNKNOWN', 'target_roas' => isset( $row['campaign']['targetRoas']['targetRoas'] ) ? (float) $row['campaign']['targetRoas']['targetRoas'] : 0, 'budget' => isset( $row['campaignBudget']['amountMicros'] ) ? (float) $row['campaignBudget']['amountMicros'] / 1000000 : 0, 'cost_total' => 0, 'conversion_value' => 0, ]; } $campaigns[ $cid ]['cost_total'] += (float) ( $row['metrics']['costMicros'] ?? 0 ); $campaigns[ $cid ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); } // Przeliczenie micros i ROAS foreach ( $campaigns as &$c ) { $c['money_spent'] = $c['cost_total'] / 1000000; $c['roas_30_days'] = ( $c['money_spent'] > 0 ) ? round( ( $c['conversion_value'] / $c['money_spent'] ) * 100, 2 ) : 0; unset( $c['cost_total'] ); } return array_values( $campaigns ); } // --- Kampanie: dane all-time --- public function get_campaigns_all_time( $customer_id ) { $gaql = "SELECT " . "campaign.id, " . "metrics.cost_micros, " . "metrics.conversions_value " . "FROM campaign " . "WHERE campaign.status = 'ENABLED'"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; $campaigns = []; foreach ( $results as $row ) { $cid = $row['campaign']['id'] ?? null; if ( !$cid ) continue; $cost = (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; $value = (float) ( $row['metrics']['conversionsValue'] ?? 0 ); $campaigns[] = [ 'campaign_id' => $cid, 'cost_all_time' => $cost, 'conversion_value_all_time' => $value, 'roas_all_time' => ( $cost > 0 ) ? round( ( $value / $cost ) * 100, 2 ) : 0, ]; } return $campaigns; } public function get_ad_groups_30_days( $customer_id ) { $gaql = "SELECT " . "campaign.id, " . "ad_group.id, " . "ad_group.name, " . "metrics.impressions, " . "metrics.clicks, " . "metrics.cost_micros, " . "metrics.conversions, " . "metrics.conversions_value " . "FROM ad_group " . "WHERE campaign.status != 'REMOVED' " . "AND ad_group.status != 'REMOVED' " . "AND segments.date DURING LAST_30_DAYS"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; return $this -> aggregate_ad_groups( $results ); } public function get_ad_groups_all_time( $customer_id ) { $gaql = "SELECT " . "campaign.id, " . "ad_group.id, " . "ad_group.name, " . "metrics.impressions, " . "metrics.clicks, " . "metrics.cost_micros, " . "metrics.conversions, " . "metrics.conversions_value " . "FROM ad_group " . "WHERE campaign.status != 'REMOVED' " . "AND ad_group.status != 'REMOVED'"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; return $this -> aggregate_ad_groups( $results ); } public function get_search_terms_30_days( $customer_id ) { $gaql = "SELECT " . "campaign.id, " . "ad_group.id, " . "ad_group.name, " . "search_term_view.search_term, " . "metrics.impressions, " . "metrics.clicks, " . "metrics.cost_micros, " . "metrics.conversions, " . "metrics.conversions_value " . "FROM search_term_view " . "WHERE campaign.status != 'REMOVED' " . "AND ad_group.status != 'REMOVED' " . "AND metrics.clicks > 0 " . "AND segments.date DURING LAST_30_DAYS"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; return $this -> aggregate_search_terms( $results ); } public function get_search_terms_all_time( $customer_id ) { $gaql = "SELECT " . "campaign.id, " . "ad_group.id, " . "ad_group.name, " . "search_term_view.search_term, " . "metrics.impressions, " . "metrics.clicks, " . "metrics.cost_micros, " . "metrics.conversions, " . "metrics.conversions_value " . "FROM search_term_view " . "WHERE campaign.status != 'REMOVED' " . "AND ad_group.status != 'REMOVED' " . "AND metrics.clicks > 0"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; return $this -> aggregate_search_terms( $results ); } public function get_negative_keywords( $customer_id ) { $campaign_gaql = "SELECT " . "campaign.id, " . "campaign_criterion.keyword.text, " . "campaign_criterion.keyword.match_type " . "FROM campaign_criterion " . "WHERE campaign.status != 'REMOVED' " . "AND campaign_criterion.type = 'KEYWORD' " . "AND campaign_criterion.negative = TRUE"; $ad_group_gaql = "SELECT " . "campaign.id, " . "ad_group.id, " . "ad_group_criterion.keyword.text, " . "ad_group_criterion.keyword.match_type " . "FROM ad_group_criterion " . "WHERE campaign.status != 'REMOVED' " . "AND ad_group.status != 'REMOVED' " . "AND ad_group_criterion.type = 'KEYWORD' " . "AND ad_group_criterion.negative = TRUE"; $campaign_results = $this -> search_stream( $customer_id, $campaign_gaql ); if ( $campaign_results === false ) return false; $ad_group_results = $this -> search_stream( $customer_id, $ad_group_gaql ); if ( $ad_group_results === false ) return false; $negatives = []; $seen = []; foreach ( $campaign_results as $row ) { $campaign_id = $row['campaign']['id'] ?? null; $text = trim( (string) ( $row['campaignCriterion']['keyword']['text'] ?? '' ) ); $match_type = (string) ( $row['campaignCriterion']['keyword']['matchType'] ?? '' ); if ( !$campaign_id || $text === '' ) { continue; } $key = 'campaign|' . $campaign_id . '||' . strtolower( $text ) . '|' . $match_type; if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $negatives[] = [ 'scope' => 'campaign', 'campaign_id' => (int) $campaign_id, 'ad_group_id' => null, 'keyword_text' => $text, 'match_type' => $match_type ]; } foreach ( $ad_group_results as $row ) { $campaign_id = $row['campaign']['id'] ?? null; $ad_group_id = $row['adGroup']['id'] ?? null; $text = trim( (string) ( $row['adGroupCriterion']['keyword']['text'] ?? '' ) ); $match_type = (string) ( $row['adGroupCriterion']['keyword']['matchType'] ?? '' ); if ( !$campaign_id || !$ad_group_id || $text === '' ) { continue; } $key = 'ad_group|' . $campaign_id . '|' . $ad_group_id . '|' . strtolower( $text ) . '|' . $match_type; if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $negatives[] = [ 'scope' => 'ad_group', 'campaign_id' => (int) $campaign_id, 'ad_group_id' => (int) $ad_group_id, 'keyword_text' => $text, 'match_type' => $match_type ]; } return $negatives; } private function aggregate_ad_groups( $results ) { $ad_groups = []; foreach ( $results as $row ) { $campaign_id = $row['campaign']['id'] ?? null; $ad_group_id = $row['adGroup']['id'] ?? null; if ( !$campaign_id || !$ad_group_id ) { continue; } $key = $campaign_id . '|' . $ad_group_id; if ( !isset( $ad_groups[ $key ] ) ) { $ad_groups[ $key ] = [ 'campaign_id' => (int) $campaign_id, 'ad_group_id' => (int) $ad_group_id, 'ad_group_name' => (string) ( $row['adGroup']['name'] ?? '' ), 'impressions' => 0, 'clicks' => 0, 'cost' => 0.0, 'conversions' => 0.0, 'conversion_value' => 0.0, 'roas' => 0.0 ]; } $ad_groups[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); $ad_groups[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); $ad_groups[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; $ad_groups[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); $ad_groups[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); } foreach ( $ad_groups as &$ad_group ) { $ad_group['roas'] = ( $ad_group['cost'] > 0 ) ? round( ( $ad_group['conversion_value'] / $ad_group['cost'] ) * 100, 2 ) : 0; } return array_values( $ad_groups ); } private function aggregate_search_terms( $results ) { $terms = []; foreach ( $results as $row ) { $campaign_id = $row['campaign']['id'] ?? null; $ad_group_id = $row['adGroup']['id'] ?? null; $search_term = trim( (string) ( $row['searchTermView']['searchTerm'] ?? '' ) ); if ( !$campaign_id || !$ad_group_id || $search_term === '' ) { continue; } $key = $campaign_id . '|' . $ad_group_id . '|' . strtolower( $search_term ); if ( !isset( $terms[ $key ] ) ) { $terms[ $key ] = [ 'campaign_id' => (int) $campaign_id, 'ad_group_id' => (int) $ad_group_id, 'ad_group_name' => (string) ( $row['adGroup']['name'] ?? '' ), 'search_term' => $search_term, 'impressions' => 0, 'clicks' => 0, 'cost' => 0.0, 'conversions' => 0.0, 'conversion_value' => 0.0, 'roas' => 0.0 ]; } $terms[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); $terms[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); $terms[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; $terms[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); $terms[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); } foreach ( $terms as $key => &$term ) { if ( (int) $term['clicks'] <= 0 ) { unset( $terms[ $key ] ); continue; } $term['roas'] = ( $term['cost'] > 0 ) ? round( ( $term['conversion_value'] / $term['cost'] ) * 100, 2 ) : 0; } return array_values( $terms ); } }