220 lines
7.8 KiB
C#
220 lines
7.8 KiB
C#
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"
|
||
};
|
||
}
|