Files
AdPlatform-Server/GoogleApi/Services/KeywordForecastService.cs
2026-03-14 13:50:09 -07:00

220 lines
7.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
};
}