Initial import into Gitea
This commit is contained in:
219
GoogleApi/Services/KeywordForecastService.cs
Normal file
219
GoogleApi/Services/KeywordForecastService.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user