Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -0,0 +1,318 @@
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Services;
using Google.Ads.GoogleAds.V22.Resources;
using Google.Ads.GoogleAds.V22.Enums;
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
// Alias to avoid namespace conflicts
using GAdsServices = global::Google.Ads.GoogleAds.Services;
namespace GoogleApi.Services;
/// <summary>
/// Service for querying Google Ads audience segments and geo targets.
/// </summary>
public sealed class AudienceService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<AudienceService> _logger;
public AudienceService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<AudienceService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
/// <summary>
/// Get all available audience segments (affinity, in-market, life events, detailed demographics)
/// </summary>
public async Task<ProviderResponse> GetAudienceSegmentsAsync(
GoogleAdsContext context,
string requestId,
CancellationToken ct)
{
_logger.LogInformation("[Audience] Fetching audience segments | RequestId={RequestId}", requestId);
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
{
return GetEmulatedAudienceSegments(requestId);
}
try
{
var client = _clientFactory.CreateClient(context);
var googleAdsService = client.GetService(GAdsServices.V22.GoogleAdsService);
var customerId = context.CustomerId;
var response = new AudienceSegmentsResponse();
// Query user interests (Affinity + In-Market)
var userInterestQuery = @"
SELECT
user_interest.user_interest_id,
user_interest.name,
user_interest.taxonomy_type,
user_interest.availabilities
FROM user_interest
WHERE user_interest.taxonomy_type IN ('AFFINITY', 'IN_MARKET')";
var userInterestResults = googleAdsService.Search(customerId, userInterestQuery);
foreach (var row in userInterestResults)
{
var ui = row.UserInterest;
var segment = new AudienceSegment
{
Id = ui.UserInterestId,
Name = ui.Name,
Type = ui.TaxonomyType.ToString()
};
if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.Affinity)
response.Affinity.Add(segment);
else if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.InMarket)
response.InMarket.Add(segment);
}
// Query life events
var lifeEventQuery = @"
SELECT
life_event.id,
life_event.name
FROM life_event";
var lifeEventResults = googleAdsService.Search(customerId, lifeEventQuery);
foreach (var row in lifeEventResults)
{
var le = row.LifeEvent;
response.LifeEvents.Add(new AudienceSegment
{
Id = le.Id,
Name = le.Name,
Type = "LIFE_EVENT"
});
}
// Query detailed demographics
var detailedDemoQuery = @"
SELECT
detailed_demographic.id,
detailed_demographic.name
FROM detailed_demographic";
var detailedDemoResults = googleAdsService.Search(customerId, detailedDemoQuery);
foreach (var row in detailedDemoResults)
{
var dd = row.DetailedDemographic;
response.DetailedDemographics.Add(new AudienceSegment
{
Id = dd.Id,
Name = dd.Name,
Type = "DETAILED_DEMOGRAPHIC"
});
}
_logger.LogInformation(
"[Audience] Retrieved {Total} segments (Affinity={Affinity}, InMarket={InMarket}, LifeEvents={Life}, Demographics={Demo}) | RequestId={RequestId}",
response.TotalCount, response.Affinity.Count, response.InMarket.Count,
response.LifeEvents.Count, response.DetailedDemographics.Count, requestId);
return ProviderResponse.Success(requestId, response);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Audience] Failed to fetch segments | RequestId={RequestId}", requestId);
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
/// <summary>
/// Search for geo target constants by name
/// </summary>
public async Task<ProviderResponse> SearchGeoTargetsAsync(
GeoTargetSearchPayload payload,
GoogleAdsContext context,
string requestId,
CancellationToken ct)
{
_logger.LogInformation("[Audience] Searching geo targets: {Query} | RequestId={RequestId}",
payload.Query, requestId);
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
{
return GetEmulatedGeoTargets(payload.Query, requestId);
}
try
{
var client = _clientFactory.CreateClient(context);
var geoService = client.GetService(GAdsServices.V22.GeoTargetConstantService);
var request = new SuggestGeoTargetConstantsRequest
{
Locale = "en",
CountryCode = payload.CountryCode ?? "US",
LocationNames = new SuggestGeoTargetConstantsRequest.Types.LocationNames()
};
request.LocationNames.Names.Add(payload.Query);
var response = await geoService.SuggestGeoTargetConstantsAsync(request);
var results = response.GeoTargetConstantSuggestions
.Take(payload.MaxResults)
.Select(s => new GeoTarget
{
Id = s.GeoTargetConstant.Id,
Name = s.GeoTargetConstant.Name,
CanonicalName = s.GeoTargetConstant.CanonicalName,
TargetType = s.GeoTargetConstant.TargetType,
CountryCode = s.GeoTargetConstant.CountryCode,
ParentGeoTarget = s.GeoTargetConstant.ParentGeoTarget
})
.ToList();
_logger.LogInformation("[Audience] Found {Count} geo targets for '{Query}' | RequestId={RequestId}",
results.Count, payload.Query, requestId);
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
{
Query = payload.Query,
Results = results
});
}
catch (Exception ex)
{
_logger.LogError(ex, "[Audience] Failed to search geo targets | RequestId={RequestId}", requestId);
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
#region Emulated Responses
private ProviderResponse GetEmulatedAudienceSegments(string requestId)
{
_logger.LogInformation("[Audience] Returning emulated audience segments | RequestId={RequestId}", requestId);
var response = new AudienceSegmentsResponse
{
Affinity = new List<AudienceSegment>
{
new() { Id = 80001, Name = "Sports & Fitness/Sports Fans", Type = "AFFINITY" },
new() { Id = 80002, Name = "Sports & Fitness/Health & Fitness Buffs", Type = "AFFINITY" },
new() { Id = 80003, Name = "Technology/Technophiles", Type = "AFFINITY" },
new() { Id = 80004, Name = "Travel/Travel Buffs", Type = "AFFINITY" },
new() { Id = 80005, Name = "Food & Dining/Foodies", Type = "AFFINITY" },
new() { Id = 80006, Name = "Home & Garden/Home Decor Enthusiasts", Type = "AFFINITY" },
new() { Id = 80007, Name = "Media & Entertainment/Movie Lovers", Type = "AFFINITY" },
new() { Id = 80008, Name = "Media & Entertainment/Music Lovers", Type = "AFFINITY" },
new() { Id = 80009, Name = "Shoppers/Value Shoppers", Type = "AFFINITY" },
new() { Id = 80010, Name = "Shoppers/Luxury Shoppers", Type = "AFFINITY" },
new() { Id = 80011, Name = "Lifestyles & Hobbies/Pet Lovers", Type = "AFFINITY" },
new() { Id = 80012, Name = "Lifestyles & Hobbies/Outdoor Enthusiasts", Type = "AFFINITY" },
new() { Id = 80013, Name = "News & Politics/Avid News Readers", Type = "AFFINITY" },
new() { Id = 80014, Name = "Beauty & Wellness/Beauty Mavens", Type = "AFFINITY" },
new() { Id = 80015, Name = "Vehicles & Transportation/Auto Enthusiasts", Type = "AFFINITY" },
},
InMarket = new List<AudienceSegment>
{
new() { Id = 90001, Name = "Apparel & Accessories/Athletic Apparel", Type = "IN_MARKET" },
new() { Id = 90002, Name = "Autos & Vehicles/Motor Vehicles (New)", Type = "IN_MARKET" },
new() { Id = 90003, Name = "Autos & Vehicles/Motor Vehicles (Used)", Type = "IN_MARKET" },
new() { Id = 90004, Name = "Business Services/Advertising & Marketing Services", Type = "IN_MARKET" },
new() { Id = 90005, Name = "Consumer Electronics/Computers & Peripherals", Type = "IN_MARKET" },
new() { Id = 90006, Name = "Consumer Electronics/Mobile Phones", Type = "IN_MARKET" },
new() { Id = 90007, Name = "Education/Primary & Secondary Schools (K-12)", Type = "IN_MARKET" },
new() { Id = 90008, Name = "Employment/Jobs", Type = "IN_MARKET" },
new() { Id = 90009, Name = "Financial Services/Insurance/Auto Insurance", Type = "IN_MARKET" },
new() { Id = 90010, Name = "Financial Services/Investment Services", Type = "IN_MARKET" },
new() { Id = 90011, Name = "Home & Garden/Home Improvement", Type = "IN_MARKET" },
new() { Id = 90012, Name = "Real Estate/Residential Properties", Type = "IN_MARKET" },
new() { Id = 90013, Name = "Software/Business Software", Type = "IN_MARKET" },
new() { Id = 90014, Name = "Travel/Hotels & Accommodations", Type = "IN_MARKET" },
new() { Id = 90015, Name = "Travel/Air Travel", Type = "IN_MARKET" },
},
LifeEvents = new List<AudienceSegment>
{
new() { Id = 70001, Name = "About to graduate college", Type = "LIFE_EVENT" },
new() { Id = 70002, Name = "Getting married soon", Type = "LIFE_EVENT" },
new() { Id = 70003, Name = "Recently married", Type = "LIFE_EVENT" },
new() { Id = 70004, Name = "Moving soon", Type = "LIFE_EVENT" },
new() { Id = 70005, Name = "Recently moved", Type = "LIFE_EVENT" },
new() { Id = 70006, Name = "Purchasing a home soon", Type = "LIFE_EVENT" },
new() { Id = 70007, Name = "Recently purchased a home", Type = "LIFE_EVENT" },
new() { Id = 70008, Name = "Starting a new job", Type = "LIFE_EVENT" },
new() { Id = 70009, Name = "Retiring soon", Type = "LIFE_EVENT" },
new() { Id = 70010, Name = "Recently started a business", Type = "LIFE_EVENT" },
},
DetailedDemographics = new List<AudienceSegment>
{
new() { Id = 60001, Name = "Parental status/Parents/Parents of infants (0-1 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60002, Name = "Parental status/Parents/Parents of toddlers (1-3 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60003, Name = "Parental status/Parents/Parents of preschoolers (3-5 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60004, Name = "Parental status/Parents/Parents of grade schoolers (6-12 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60005, Name = "Parental status/Parents/Parents of teens (13-17 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60006, Name = "Marital status/Single", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60007, Name = "Marital status/In a relationship", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60008, Name = "Marital status/Married", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60009, Name = "Education/Current college students", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60010, Name = "Education/Bachelor's degree", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60011, Name = "Education/Advanced degree", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60012, Name = "Homeownership/Homeowners", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60013, Name = "Homeownership/Renters", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60014, Name = "Employment/Industry/Technology", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60015, Name = "Employment/Company size/Large employers (10,000+)", Type = "DETAILED_DEMOGRAPHIC" },
},
RetrievedAt = DateTimeOffset.UtcNow
};
return ProviderResponse.Success(requestId, response);
}
private ProviderResponse GetEmulatedGeoTargets(string query, string requestId)
{
_logger.LogInformation("[Audience] Returning emulated geo targets for '{Query}' | RequestId={RequestId}",
query, requestId);
var allTargets = new List<GeoTarget>
{
new() { Id = 9061285, Name = "Huntington Beach", CanonicalName = "Huntington Beach,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 1013962, Name = "Orange County", CanonicalName = "Orange County,California,United States", TargetType = "County", CountryCode = "US" },
new() { Id = 21137, Name = "California", CanonicalName = "California,United States", TargetType = "State", CountryCode = "US" },
new() { Id = 1014221, Name = "Los Angeles", CanonicalName = "Los Angeles,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 1014218, Name = "Los Angeles County", CanonicalName = "Los Angeles County,California,United States", TargetType = "County", CountryCode = "US" },
new() { Id = 9031936, Name = "Irvine", CanonicalName = "Irvine,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 9031935, Name = "Costa Mesa", CanonicalName = "Costa Mesa,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 9031938, Name = "Newport Beach", CanonicalName = "Newport Beach,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 9031937, Name = "Santa Ana", CanonicalName = "Santa Ana,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 1023191, Name = "New York", CanonicalName = "New York,New York,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 21167, Name = "New York", CanonicalName = "New York,United States", TargetType = "State", CountryCode = "US" },
new() { Id = 1014895, Name = "Chicago", CanonicalName = "Chicago,Illinois,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 2840, Name = "United States", CanonicalName = "United States", TargetType = "Country", CountryCode = "US" },
};
// Simple filter by query
var queryLower = query.ToLowerInvariant();
var matches = allTargets
.Where(t => t.Name.ToLowerInvariant().Contains(queryLower) ||
t.CanonicalName.ToLowerInvariant().Contains(queryLower))
.Take(10)
.ToList();
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
{
Query = query,
Results = matches
});
}
#endregion
}

View File

@@ -1,4 +1,4 @@
using GoogleApi.Configuration;
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
@@ -23,15 +23,24 @@ public sealed class GoogleAdsService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly AudienceService _audienceService;
private readonly ReportingService _reportingService;
private readonly KeywordForecastService _forecastService;
private readonly ILogger<GoogleAdsService> _logger;
public GoogleAdsService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
AudienceService audienceService,
ReportingService reportingService,
KeywordForecastService forecastService,
ILogger<GoogleAdsService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_audienceService = audienceService;
_reportingService = reportingService;
_forecastService = forecastService;
_logger = logger;
}
@@ -65,7 +74,20 @@ public sealed class GoogleAdsService
"GetCampaignStats" => GetCampaignStats(request, requestId),
"GetAccountStats" => GetAccountStats(request, requestId),
// Reporting (Campaign Intelligence)
"GetCampaignReport" => await _reportingService.GetCampaignReportAsync(request, context, requestId, ct),
"GetAccountReport" => await _reportingService.GetAccountReportAsync(request, context, requestId, ct),
"ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct),
"CreateCustomerClient" => await CreateCustomerClientAsync(request, context, requestId, ct),
// Audience Operations
"GetAudienceSegments" => await _audienceService.GetAudienceSegmentsAsync(context, requestId, ct),
"SearchGeoTargets" => await _audienceService.SearchGeoTargetsAsync(
request.GetPayload<GeoTargetSearchPayload>(), context, requestId, ct),
// Forecast
"KeywordForecast" => await ExecuteForecastAsync(request, context, requestId, ct),
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
@@ -94,6 +116,14 @@ public sealed class GoogleAdsService
timestamp = DateTimeOffset.UtcNow
});
private async Task<ProviderResponse> ExecuteForecastAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<KeywordForecastPayload>();
var result = await _forecastService.ForecastAsync(payload, context, ct);
return ProviderResponse.Success(requestId, result);
}
private async Task<ProviderResponse> CreateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
@@ -495,6 +525,100 @@ ORDER BY campaign.name";
}
}
private async Task<ProviderResponse> CreateCustomerClientAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<CreateCustomerClientPayload>();
if (string.IsNullOrWhiteSpace(payload.AccountName))
return ProviderResponse.Fail(requestId, "VALIDATION", "AccountName is required");
// For emulated mode
if (!_clientFactory.IsRealApiEnabled)
{
var fakeId = new Random().NextInt64(1000000000, 9999999999).ToString();
_logger.LogInformation("[GoogleAds] EMULATED: Created customer client {AccountName} => {CustomerId}",
payload.AccountName, fakeId);
return ProviderResponse.Success(requestId, new
{
customerId = fakeId,
accountName = payload.AccountName,
currencyCode = payload.CurrencyCode,
timeZone = payload.TimeZone,
emulated = true
});
}
try
{
// CreateCustomerClient runs against the MCC (manager account).
// The context.CustomerId should be the MCC ID.
var mccId = GoogleAdsClientFactory.NormalizeCustomerId(
context.CustomerId ?? request.TenantId ?? string.Empty);
if (string.IsNullOrWhiteSpace(mccId))
return ProviderResponse.Fail(requestId, "VALIDATION",
"TenantId (MCC customer ID) is required to create a sub-account");
// Force LoginCustomerId = MCC for this call
var mccContext = new GoogleAdsContext
{
CustomerId = mccId,
LoginCustomerId = mccId
};
GoogleAdsClient client = _clientFactory.CreateClient(mccContext);
CustomerServiceClient customerService =
client.GetService(GAdsServices.V22.CustomerService);
var customerClient = new Customer
{
DescriptiveName = payload.DescriptiveName ?? payload.AccountName,
CurrencyCode = payload.CurrencyCode,
TimeZone = payload.TimeZone
};
var response = await customerService.CreateCustomerClientAsync(
new CreateCustomerClientRequest
{
CustomerId = mccId,
CustomerClient = customerClient
},
cancellationToken: ct);
// Response contains the resource name like "customers/9336988646/customerClients/1234567890"
var newCustomerId = response.ResourceName?.Split('/').LastOrDefault() ?? response.ResourceName;
_logger.LogInformation(
"[GoogleAds] Created customer client {AccountName} => {NewCustomerId} under MCC {MccId}",
payload.AccountName, newCustomerId, mccId);
return ProviderResponse.Success(requestId, new
{
customerId = newCustomerId,
resourceName = response.ResourceName,
accountName = payload.AccountName,
descriptiveName = payload.DescriptiveName ?? payload.AccountName,
currencyCode = payload.CurrencyCode,
timeZone = payload.TimeZone,
mccId = mccId,
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error creating customer client | RequestId={RequestId}", requestId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create customer client via real API");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
private static string GenerateId() => Guid.NewGuid().ToString("N")[..12];
private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type)

View File

@@ -0,0 +1,219 @@
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Services;
using Google.Ads.GoogleAds.V22.Common;
using Google.Ads.GoogleAds.V22.Enums;
using Google.Ads.GoogleAds.V22.Resources;
namespace GoogleApi.Services;
using GAdsServices = global::Google.Ads.GoogleAds.Services;
/// <summary>
/// Generates keyword-level forecast metrics via Google Ads KeywordPlanIdeaService.
/// Used by the wizard to show estimated performance before campaign creation.
///
/// EnableRealApi=false → emulated estimates based on budget + keyword count.
/// EnableRealApi=true → calls GenerateKeywordForecastMetrics.
/// </summary>
public sealed class KeywordForecastService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<KeywordForecastService> _logger;
public KeywordForecastService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<KeywordForecastService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
public async Task<KeywordForecastResponse> ForecastAsync(
KeywordForecastPayload payload,
GoogleAdsContext context,
CancellationToken ct)
{
if (payload.Keywords.Count == 0)
{
_logger.LogWarning("[KeywordForecast] No keywords provided");
return EmptyForecast();
}
if (_clientFactory.IsRealApiEnabled)
{
try
{
return await ForecastRealAsync(payload, context, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "[KeywordForecast] Real API failed, falling back to emulated");
return ForecastEmulated(payload);
}
}
return ForecastEmulated(payload);
}
// ================================================================
// Real API: GenerateKeywordForecastMetrics
// ================================================================
private async Task<KeywordForecastResponse> ForecastRealAsync(
KeywordForecastPayload payload,
GoogleAdsContext context,
CancellationToken ct)
{
_logger.LogInformation(
"[KeywordForecast] Real API | Keywords={Count} Budget={Budget} Geos={Geos}",
payload.Keywords.Count, payload.MonthlyBudget, payload.GeoTargetIds.Count);
var client = _clientFactory.CreateClient(context);
var service = client.GetService(GAdsServices.V22.KeywordPlanIdeaService);
// Build campaign to forecast
var campaign = new CampaignToForecast
{
KeywordPlanNetwork = KeywordPlanNetworkEnum.Types.KeywordPlanNetwork.GoogleSearch
};
// Geo targets
foreach (var geoId in payload.GeoTargetIds)
{
campaign.GeoModifiers.Add(new CriterionBidModifier
{
GeoTargetConstant = GeoTargetConstantName.Format(geoId.ToString())
});
}
// Ad group with keywords (cap at 20 for sanity)
var adGroup = new ForecastAdGroup();
foreach (var keyword in payload.Keywords.Take(20))
{
adGroup.BiddableKeywords.Add(new BiddableKeyword
{
MaxCpcBidMicros = 2_000_000, // $2.00 default bid for simulation
Keyword = new KeywordInfo
{
Text = keyword,
MatchType = KeywordMatchTypeEnum.Types.KeywordMatchType.Broad
}
});
}
campaign.AdGroups.Add(adGroup);
var request = new GenerateKeywordForecastMetricsRequest
{
Campaign = campaign,
ForecastPeriod = new DateRange
{
StartDate = DateTime.UtcNow.ToString("yyyy-MM-dd"),
EndDate = DateTime.UtcNow.AddDays(payload.ForecastDays).ToString("yyyy-MM-dd")
},
CustomerId = context.CustomerId
};
var response = await service.GenerateKeywordForecastMetricsAsync(request);
var m = response.CampaignForecastMetrics;
// V22 SDK returns non-nullable primitives (0 when no data)
var impressions = m.Impressions;
var clicks = m.Clicks;
var costMicros = m.CostMicros;
var conversions = m.Conversions;
var avgCpcMicros = m.AverageCpcMicros;
var cost = costMicros / 1_000_000m;
var avgCpc = avgCpcMicros / 1_000_000m;
var ctr = impressions > 0 ? clicks / impressions : 0;
var avgCpm = impressions > 0 ? (cost / (decimal)impressions) * 1000m : 0;
var cpa = conversions > 0 ? cost / (decimal)conversions : (decimal?)null;
_logger.LogInformation(
"[KeywordForecast] Real result | Imp={Imp} Clicks={Clicks} Cost={Cost}",
impressions, clicks, cost);
return new KeywordForecastResponse
{
Provider = "google",
Monthly = new ForecastEstimates
{
Impressions = impressions,
Clicks = clicks,
Cost = cost,
Conversions = conversions
},
Metrics = new ForecastMetrics
{
AvgCpc = avgCpc,
AvgCpm = avgCpm,
Ctr = ctr,
EstimatedCpa = cpa
},
Confidence = "medium",
DataSource = "keywordForecast"
};
}
// ================================================================
// Emulated: budget-proportional estimates with realistic variance
// ================================================================
private KeywordForecastResponse ForecastEmulated(KeywordForecastPayload payload)
{
_logger.LogInformation(
"[KeywordForecast] Emulated | Keywords={Count} Budget={Budget}",
payload.Keywords.Count, payload.MonthlyBudget);
// Realistic Search ranges for SMB tier
var keywordFactor = Math.Min(payload.Keywords.Count, 20) / 20.0;
var baseCpc = 2.50m - (decimal)(keywordFactor * 1.20); // $1.30 $2.50
var budget = payload.MonthlyBudget;
var clicks = budget > 0 ? (double)(budget / baseCpc) : 0;
var impressions = clicks / 0.045; // ~4.5% CTR
var conversions = clicks * 0.035; // ~3.5% conv rate
var ctr = impressions > 0 ? clicks / impressions : 0;
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
// Seeded variance (±15%) — same inputs → same output, but not canned-looking
var rng = new Random((int)(budget * 100) + payload.Keywords.Count);
var v = 0.85 + (rng.NextDouble() * 0.30);
return new KeywordForecastResponse
{
Provider = "google",
Monthly = new ForecastEstimates
{
Impressions = Math.Round(impressions * v),
Clicks = Math.Round(clicks * v),
Cost = Math.Round(budget * (decimal)v, 2),
Conversions = Math.Round(conversions * v, 1)
},
Metrics = new ForecastMetrics
{
AvgCpc = Math.Round(baseCpc, 2),
AvgCpm = Math.Round((decimal)cpm, 2),
Ctr = Math.Round(ctr, 4),
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null
},
Confidence = "low",
DataSource = "emulated"
};
}
private static KeywordForecastResponse EmptyForecast() => new()
{
Provider = "google",
Confidence = "none",
DataSource = "none"
};
}

View File

@@ -0,0 +1,382 @@
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
using Google.Ads.GoogleAds;
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Errors;
using Google.Ads.GoogleAds.V22.Services;
namespace GoogleApi.Services;
using GAdsServices = global::Google.Ads.GoogleAds.Services;
/// <summary>
/// Reporting service for pulling campaign performance metrics from Google Ads.
/// Supports both real API calls and emulated responses for development.
///
/// Operations:
/// - GetCampaignReport: Daily metrics for a specific campaign
/// - GetAccountReport: Daily metrics across all campaigns in an account
/// - GetCampaignList: Campaign status/budget summary (lightweight)
/// </summary>
public sealed class ReportingService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<ReportingService> _logger;
public ReportingService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<ReportingService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
/// <summary>
/// Get daily campaign performance report.
/// Returns impressions, clicks, spend, conversions, conversion value per day.
/// </summary>
public async Task<ProviderResponse> GetCampaignReportAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<ReportingPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd");
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await GetCampaignReportRealAsync(payload.CampaignId, startDate, endDate, context, requestId, ct);
return GetCampaignReportEmulated(payload.CampaignId, startDate, endDate, requestId);
}
/// <summary>
/// Get daily account-level performance report across all campaigns.
/// Used for syncing metrics into tbPerformanceMetric.
/// </summary>
public async Task<ProviderResponse> GetAccountReportAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<ReportingPayload>();
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd");
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await GetAccountReportRealAsync(startDate, endDate, context, requestId, ct);
return GetAccountReportEmulated(startDate, endDate, requestId);
}
// ════════════════════════════════════════════════
// Real API Implementation
// ════════════════════════════════════════════════
private async Task<ProviderResponse> GetCampaignReportRealAsync(
string campaignId, string startDate, string endDate,
GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
var client = _clientFactory.CreateClient(context);
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
// Extract numeric campaign ID from resource name if needed
var numericId = campaignId.Contains('/') ? campaignId.Split('/').Last() : campaignId;
var query = $@"
SELECT
campaign.id,
campaign.name,
campaign.status,
segments.date,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.all_conversions,
metrics.ctr,
metrics.average_cpc
FROM campaign
WHERE campaign.id = {numericId}
AND segments.date BETWEEN '{startDate}' AND '{endDate}'
ORDER BY segments.date";
var rows = new List<object>();
var searchRequest = new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
};
var response = gaService.Search(searchRequest);
foreach (var row in response)
{
rows.Add(new
{
date = row.Segments.Date,
campaignId = row.Campaign.Id,
campaignName = row.Campaign.Name,
campaignStatus = row.Campaign.Status.ToString(),
impressions = row.Metrics.Impressions,
clicks = row.Metrics.Clicks,
costMicros = row.Metrics.CostMicros,
spend = row.Metrics.CostMicros / 1_000_000.0,
conversions = row.Metrics.Conversions,
conversionValue = row.Metrics.ConversionsValue,
allConversions = row.Metrics.AllConversions,
ctr = row.Metrics.Ctr,
averageCpcMicros = row.Metrics.AverageCpc
});
}
_logger.LogInformation(
"[Reporting] Retrieved {Count} rows for campaign {CampaignId} | RequestId={RequestId}",
rows.Count, campaignId, requestId);
return ProviderResponse.Success(requestId, new
{
campaignId,
dateRange = new { start = startDate, end = endDate },
rows,
rowCount = rows.Count,
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "[Reporting] Google Ads error for campaign {CampaignId}", campaignId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Reporting] Error fetching campaign report");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
private async Task<ProviderResponse> GetAccountReportRealAsync(
string startDate, string endDate,
GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
var client = _clientFactory.CreateClient(context);
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
var query = $@"
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
segments.date,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.all_conversions
FROM campaign
WHERE segments.date BETWEEN '{startDate}' AND '{endDate}'
AND campaign.status != 'REMOVED'
ORDER BY segments.date, campaign.id";
var rows = new List<object>();
var searchRequest = new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
};
var response = gaService.Search(searchRequest);
foreach (var row in response)
{
rows.Add(new
{
date = row.Segments.Date,
campaignId = row.Campaign.Id,
campaignName = row.Campaign.Name,
campaignStatus = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
impressions = row.Metrics.Impressions,
clicks = row.Metrics.Clicks,
costMicros = row.Metrics.CostMicros,
spend = row.Metrics.CostMicros / 1_000_000.0,
conversions = row.Metrics.Conversions,
conversionValue = row.Metrics.ConversionsValue,
allConversions = row.Metrics.AllConversions
});
}
_logger.LogInformation(
"[Reporting] Retrieved {Count} rows for account {CustomerId} | RequestId={RequestId}",
rows.Count, context.CustomerId, requestId);
return ProviderResponse.Success(requestId, new
{
customerId = context.CustomerId,
dateRange = new { start = startDate, end = endDate },
rows,
rowCount = rows.Count,
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "[Reporting] Google Ads error for account {CustomerId}", context.CustomerId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Reporting] Error fetching account report");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
// ════════════════════════════════════════════════
// Emulated Responses
// ════════════════════════════════════════════════
private ProviderResponse GetCampaignReportEmulated(
string campaignId, string startDate, string endDate, string requestId)
{
_logger.LogInformation("[Reporting] EMULATED: Campaign report for {CampaignId}", campaignId);
var rows = GenerateEmulatedDailyRows(startDate, endDate);
return ProviderResponse.Success(requestId, new
{
campaignId,
dateRange = new { start = startDate, end = endDate },
rows,
rowCount = rows.Count,
emulated = true
});
}
private ProviderResponse GetAccountReportEmulated(
string startDate, string endDate, string requestId)
{
_logger.LogInformation("[Reporting] EMULATED: Account report");
// Generate rows for 3 emulated campaigns
var allRows = new List<object>();
var campaigns = new[]
{
new { id = "emu_camp_001", name = "Search - Brand Terms", channel = "SEARCH" },
new { id = "emu_camp_002", name = "Display - Awareness", channel = "DISPLAY" },
new { id = "emu_camp_003", name = "PMax - Conversions", channel = "PERFORMANCE_MAX" }
};
foreach (var camp in campaigns)
{
var rows = GenerateEmulatedDailyRows(startDate, endDate);
foreach (var row in rows)
{
// Merge campaign info into each row
var dict = new Dictionary<string, object?>
{
["campaignId"] = camp.id,
["campaignName"] = camp.name,
["campaignStatus"] = "ENABLED",
["channelType"] = camp.channel
};
// Merge the row properties
var rowJson = System.Text.Json.JsonSerializer.Serialize(row);
var rowDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(rowJson);
if (rowDict != null)
foreach (var kv in rowDict) dict[kv.Key] = kv.Value;
allRows.Add(dict);
}
}
return ProviderResponse.Success(requestId, new
{
customerId = "emulated",
dateRange = new { start = startDate, end = endDate },
rows = allRows,
rowCount = allRows.Count,
emulated = true
});
}
private static List<object> GenerateEmulatedDailyRows(string startDate, string endDate)
{
var rows = new List<object>();
var rng = new Random(42); // deterministic seed for consistent emulation
if (!DateTime.TryParse(startDate, out var start)) start = DateTime.UtcNow.AddDays(-30);
if (!DateTime.TryParse(endDate, out var end)) end = DateTime.UtcNow.AddDays(-1);
for (var date = start; date <= end; date = date.AddDays(1))
{
var impressions = rng.Next(800, 3500);
var clicks = (int)(impressions * (0.015 + rng.NextDouble() * 0.04));
var costMicros = (long)(clicks * (450_000 + rng.Next(0, 300_000)));
var conversions = Math.Round(clicks * (0.03 + rng.NextDouble() * 0.05), 2);
var conversionValue = Math.Round(conversions * (25 + rng.NextDouble() * 75), 2);
rows.Add(new
{
date = date.ToString("yyyy-MM-dd"),
impressions,
clicks,
costMicros,
spend = Math.Round(costMicros / 1_000_000.0, 2),
conversions,
conversionValue,
allConversions = Math.Round(conversions * 1.15, 2),
ctr = impressions > 0 ? Math.Round((double)clicks / impressions, 4) : 0.0,
averageCpcMicros = clicks > 0 ? costMicros / clicks : 0L
});
}
return rows;
}
// ════════════════════════════════════════════════
// Error Handling
// ════════════════════════════════════════════════
private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId)
{
var errorDetails = gex.Failure?.Errors?.Select(e => new
{
errorCode = e.ErrorCode?.ToString(),
message = e.Message,
trigger = e.Trigger?.StringValue,
location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray()
}).ToList();
return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new
{
googleRequestId = gex.RequestId,
errors = errorDetails
});
}
}
/// <summary>
/// Payload for reporting operations.
/// </summary>
public sealed class ReportingPayload
{
public string? CampaignId { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}