Initial import into Gitea
This commit is contained in:
74
Creative/Configuration/CreativeConfig.cs
Normal file
74
Creative/Configuration/CreativeConfig.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
namespace Creative.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Creative service.
|
||||
/// Bound from appsettings.json section "Creative".
|
||||
/// Override via environment variables: Creative__OpenAiApiKey, etc.
|
||||
/// </summary>
|
||||
public class CreativeConfig
|
||||
{
|
||||
public const string SectionName = "Creative";
|
||||
|
||||
/// <summary>
|
||||
/// When false, returns emulated/mock creative assets.
|
||||
/// When true, calls OpenAI and performs real URL scraping.
|
||||
/// </summary>
|
||||
public bool EnableRealApi { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI API key for copy generation.
|
||||
/// </summary>
|
||||
public string? OpenAiApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI model to use. Default: gpt-4o-mini.
|
||||
/// </summary>
|
||||
public string OpenAiModel { get; set; } = "gpt-4o-mini";
|
||||
|
||||
/// <summary>
|
||||
/// Max tokens for OpenAI responses.
|
||||
/// </summary>
|
||||
public int OpenAiMaxTokens { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for URL scraping.
|
||||
/// </summary>
|
||||
public int ScrapeTimeoutSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for OpenAI API calls.
|
||||
/// </summary>
|
||||
public int OpenAiTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
// ── Image Provider ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Image provider: "emulated" | "unsplash" | "dalle".
|
||||
/// Default: emulated (placeholder images).
|
||||
/// </summary>
|
||||
public string ImageProvider { get; set; } = "emulated";
|
||||
|
||||
/// <summary>
|
||||
/// Unsplash Access Key (optional - basic search works without it,
|
||||
/// but rate limits are generous with a free key from unsplash.com/developers).
|
||||
/// </summary>
|
||||
public string? UnsplashAccessKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of images to return per draft. Default: 3
|
||||
/// (landscape, square, portrait for responsive display ads).
|
||||
/// </summary>
|
||||
public int ImageCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// DALL-E model to use when ImageProvider=dalle.
|
||||
/// Default: dall-e-3.
|
||||
/// </summary>
|
||||
public string DalleModel { get; set; } = "dall-e-3";
|
||||
|
||||
/// <summary>
|
||||
/// DALL-E image size. Default: 1024x1024.
|
||||
/// Options: 1024x1024, 1792x1024, 1024x1792.
|
||||
/// </summary>
|
||||
public string DalleSize { get; set; } = "1024x1024";
|
||||
}
|
||||
48
Creative/Controllers/InternalController.cs
Normal file
48
Creative/Controllers/InternalController.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Creative.Models;
|
||||
using Creative.Security;
|
||||
using Creative.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Creative.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Internal endpoint called by Gateway.
|
||||
/// Single dispatch point: POST /internal/execute
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal")]
|
||||
public class InternalController : ControllerBase
|
||||
{
|
||||
private readonly CreativeService _service;
|
||||
private readonly ILogger<InternalController> _logger;
|
||||
|
||||
public InternalController(CreativeService service, ILogger<InternalController> logger)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a creative operation.
|
||||
/// Called by Gateway with X-Internal-Key header.
|
||||
/// </summary>
|
||||
[HttpPost("execute")]
|
||||
[ServiceFilter(typeof(InternalAuthFilter))]
|
||||
public async Task<IActionResult> Execute(
|
||||
[FromBody] CreativeRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var requestId = Request.Headers["X-Request-Id"].FirstOrDefault()
|
||||
?? request.RequestId
|
||||
?? Guid.NewGuid().ToString("N");
|
||||
|
||||
request.RequestId = requestId;
|
||||
|
||||
_logger.LogInformation("[Internal] {Operation} | RequestId={RequestId}",
|
||||
request.Operation, requestId);
|
||||
|
||||
var result = await _service.ExecuteAsync(request, ct);
|
||||
|
||||
return result.Ok ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
}
|
||||
23
Creative/Creative.csproj
Normal file
23
Creative/Creative.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!-- Container Settings -->
|
||||
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
|
||||
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
|
||||
<ContainerRepository>creative</ContainerRepository>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ContainerPort Include="8080" Type="tcp" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
BIN
Creative/Creative.zip
Normal file
BIN
Creative/Creative.zip
Normal file
Binary file not shown.
206
Creative/Models/CreativeModels.cs
Normal file
206
Creative/Models/CreativeModels.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Creative.Models;
|
||||
|
||||
// ============================================================
|
||||
// Request / Response envelope (matches ProviderModels pattern)
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Inbound request from Gateway via /internal/execute.
|
||||
/// </summary>
|
||||
public class CreativeRequest
|
||||
{
|
||||
[JsonPropertyName("operation")]
|
||||
public string? Operation { get; set; }
|
||||
|
||||
[JsonPropertyName("requestId")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public Dictionary<string, object>? Payload { get; set; }
|
||||
|
||||
// Session context forwarded by Gateway
|
||||
[JsonPropertyName("session")]
|
||||
public SessionContext? Session { get; set; }
|
||||
}
|
||||
|
||||
public class SessionContext
|
||||
{
|
||||
[JsonPropertyName("sessionId")]
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
[JsonPropertyName("clientId")]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[JsonPropertyName("clientName")]
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("userEmail")]
|
||||
public string? UserEmail { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard response envelope.
|
||||
/// </summary>
|
||||
public class CreativeResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok { get; set; }
|
||||
|
||||
[JsonPropertyName("requestId")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public object? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public object? Error { get; set; }
|
||||
|
||||
public static CreativeResponse Success(string requestId, object? data = null) => new()
|
||||
{
|
||||
Ok = true,
|
||||
RequestId = requestId,
|
||||
Data = data
|
||||
};
|
||||
|
||||
public static CreativeResponse Fail(string requestId, string code, string message, object? details = null) => new()
|
||||
{
|
||||
Ok = false,
|
||||
RequestId = requestId,
|
||||
Error = new { code, message, details }
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Domain models - scraped content and generated assets
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Result of scraping and analyzing a URL.
|
||||
/// </summary>
|
||||
public class UrlAnalysis
|
||||
{
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("metaDescription")]
|
||||
public string? MetaDescription { get; set; }
|
||||
|
||||
[JsonPropertyName("headings")]
|
||||
public List<string> Headings { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("bodySnippet")]
|
||||
public string? BodySnippet { get; set; }
|
||||
|
||||
[JsonPropertyName("inferredCategory")]
|
||||
public string? InferredCategory { get; set; }
|
||||
|
||||
[JsonPropertyName("scrapedAt")]
|
||||
public DateTimeOffset ScrapedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single text asset (headline or description) for Google Ads.
|
||||
/// </summary>
|
||||
public class TextAsset
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = ""; // "headline" or "description"
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("charCount")]
|
||||
public int CharCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An image asset sourced for the campaign.
|
||||
/// </summary>
|
||||
public class ImageAsset
|
||||
{
|
||||
[JsonPropertyName("imageId")]
|
||||
public string ImageId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Where the image came from: "emulated" | "unsplash" | "dalle"
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "emulated";
|
||||
|
||||
/// <summary>
|
||||
/// Orientation/aspect: "landscape" | "square" | "portrait"
|
||||
/// </summary>
|
||||
[JsonPropertyName("orientation")]
|
||||
public string Orientation { get; set; } = "landscape";
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
|
||||
[JsonPropertyName("altText")]
|
||||
public string? AltText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Attribution line (required by Unsplash TOS, informational for others).
|
||||
/// </summary>
|
||||
[JsonPropertyName("attribution")]
|
||||
public string? Attribution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Direct download/full-res URL if different from display URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete set of generated assets for a campaign draft.
|
||||
/// </summary>
|
||||
public class CampaignDraft
|
||||
{
|
||||
[JsonPropertyName("draftId")]
|
||||
public string DraftId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("analysis")]
|
||||
public UrlAnalysis? Analysis { get; set; }
|
||||
|
||||
[JsonPropertyName("headlines")]
|
||||
public List<TextAsset> Headlines { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("descriptions")]
|
||||
public List<TextAsset> Descriptions { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<ImageAsset> Images { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Copy source: "emulated" | "openai"
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "emulated";
|
||||
|
||||
/// <summary>
|
||||
/// Image source: "emulated" | "unsplash" | "dalle"
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageSource")]
|
||||
public string ImageSource { get; set; } = "emulated";
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
96
Creative/Program.cs
Normal file
96
Creative/Program.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Creative.Configuration;
|
||||
using Creative.Security;
|
||||
using Creative.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --------------------
|
||||
// Container-friendly HTTP binding
|
||||
// --------------------
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// --------------------
|
||||
// Configuration
|
||||
// --------------------
|
||||
builder.Services.Configure<CreativeConfig>(
|
||||
builder.Configuration.GetSection(CreativeConfig.SectionName));
|
||||
|
||||
var creativeConfig = builder.Configuration
|
||||
.GetSection(CreativeConfig.SectionName)
|
||||
.Get<CreativeConfig>();
|
||||
|
||||
Console.WriteLine("===========================================");
|
||||
Console.WriteLine("[Creative] Starting service...");
|
||||
Console.WriteLine($"[Creative] Emulated Mode: {!(creativeConfig?.EnableRealApi ?? false)}");
|
||||
Console.WriteLine($"[Creative] OpenAI Key Set: {!string.IsNullOrEmpty(creativeConfig?.OpenAiApiKey)}");
|
||||
Console.WriteLine($"[Creative] Image Provider: {creativeConfig?.ImageProvider ?? "emulated"}");
|
||||
Console.WriteLine($"[Creative] Unsplash Key Set: {!string.IsNullOrEmpty(creativeConfig?.UnsplashAccessKey)}");
|
||||
Console.WriteLine($"[Creative] Internal Key Set: {!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CREATIVE_INTERNAL_KEY"))}");
|
||||
Console.WriteLine("===========================================");
|
||||
|
||||
// --------------------
|
||||
// Services
|
||||
// --------------------
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "Creative Service", Version = "v1" });
|
||||
});
|
||||
|
||||
// Core services
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<ScraperService>();
|
||||
builder.Services.AddSingleton<CopyGeneratorService>();
|
||||
builder.Services.AddSingleton<ImageGeneratorService>();
|
||||
builder.Services.AddScoped<CreativeService>();
|
||||
|
||||
// Auth filter for internal calls from Gateway
|
||||
builder.Services.AddScoped<InternalAuthFilter>();
|
||||
|
||||
// --------------------
|
||||
// Build & Configure
|
||||
// --------------------
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseRouting();
|
||||
app.MapControllers();
|
||||
|
||||
// Root endpoint
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "Creative",
|
||||
status = "healthy",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
// Health check with config status
|
||||
app.MapGet("/health", (IConfiguration config) =>
|
||||
{
|
||||
var settings = config.GetSection(CreativeConfig.SectionName).Get<CreativeConfig>();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
service = "Creative",
|
||||
status = "healthy",
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
config = new
|
||||
{
|
||||
realApiEnabled = settings?.EnableRealApi ?? false,
|
||||
openAiConfigured = !string.IsNullOrEmpty(settings?.OpenAiApiKey),
|
||||
model = settings?.OpenAiModel ?? "(default)",
|
||||
imageProvider = settings?.ImageProvider ?? "emulated",
|
||||
unsplashConfigured = !string.IsNullOrEmpty(settings?.UnsplashAccessKey),
|
||||
imageCount = settings?.ImageCount ?? 3
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Console.WriteLine("[Creative] Pipeline configured, starting listener...");
|
||||
Console.WriteLine($"[Creative] Listening on http://0.0.0.0:{port}");
|
||||
|
||||
app.Run();
|
||||
15
Creative/Properties/launchSettings.json
Normal file
15
Creative/Properties/launchSettings.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5200",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Creative/Security/InternalAuthFilter.cs
Normal file
50
Creative/Security/InternalAuthFilter.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Creative.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates X-Internal-Key header on internal endpoints.
|
||||
/// Gateway sends this key when forwarding requests.
|
||||
/// </summary>
|
||||
public class InternalAuthFilter : IActionFilter
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<InternalAuthFilter> _logger;
|
||||
|
||||
public InternalAuthFilter(IConfiguration config, ILogger<InternalAuthFilter> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
// Get expected key from config or environment
|
||||
var expectedKey = _config["InternalKey"]
|
||||
?? Environment.GetEnvironmentVariable("CREATIVE_INTERNAL_KEY")
|
||||
?? "";
|
||||
|
||||
// If no key configured, allow all (dev mode)
|
||||
if (string.IsNullOrWhiteSpace(expectedKey))
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] No internal key configured - allowing all requests");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate header
|
||||
var providedKey = context.HttpContext.Request.Headers["X-Internal-Key"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providedKey) || providedKey != expectedKey)
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] Invalid or missing X-Internal-Key");
|
||||
context.Result = new UnauthorizedObjectResult(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Unauthorized: invalid internal key"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void OnActionExecuted(ActionExecutedContext context) { }
|
||||
}
|
||||
233
Creative/Services/CopyGeneratorService.cs
Normal file
233
Creative/Services/CopyGeneratorService.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Creative.Configuration;
|
||||
using Creative.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates Google Ads text assets (headlines + descriptions).
|
||||
/// Uses OpenAI API when enabled, emulated data otherwise.
|
||||
///
|
||||
/// Google Ads specs:
|
||||
/// Headlines: max 30 characters, up to 15 per RSA
|
||||
/// Descriptions: max 90 characters, up to 4 per RSA
|
||||
/// </summary>
|
||||
public class CopyGeneratorService
|
||||
{
|
||||
private const int MaxHeadlineChars = 30;
|
||||
private const int MaxDescriptionChars = 90;
|
||||
|
||||
private readonly CreativeConfig _config;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<CopyGeneratorService> _logger;
|
||||
|
||||
public CopyGeneratorService(
|
||||
IOptions<CreativeConfig> config,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<CopyGeneratorService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate text assets from analyzed URL content.
|
||||
/// Returns validated headlines and descriptions.
|
||||
/// </summary>
|
||||
public async Task<(List<TextAsset> Headlines, List<TextAsset> Descriptions, string Source)>
|
||||
GenerateAsync(UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
if (!_config.EnableRealApi || string.IsNullOrWhiteSpace(_config.OpenAiApiKey))
|
||||
return EmulateGeneration(analysis);
|
||||
|
||||
return await GenerateRealAsync(analysis, ct);
|
||||
}
|
||||
|
||||
#region Real Implementation (OpenAI)
|
||||
|
||||
private async Task<(List<TextAsset>, List<TextAsset>, string)> GenerateRealAsync(
|
||||
UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[CopyGen] Calling OpenAI for {Url}", analysis.Url);
|
||||
|
||||
var prompt = BuildPrompt(analysis);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds);
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}");
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
model = _config.OpenAiModel,
|
||||
max_tokens = _config.OpenAiMaxTokens,
|
||||
messages = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
role = "system",
|
||||
content = "You are an expert Google Ads copywriter. " +
|
||||
"Return ONLY valid JSON with no markdown formatting. " +
|
||||
"Follow character limits exactly."
|
||||
},
|
||||
new { role = "user", content = prompt }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync("https://api.openai.com/v1/chat/completions", content, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("[CopyGen] OpenAI returned {Status}: {Body}", response.StatusCode, errorBody);
|
||||
throw new InvalidOperationException($"OpenAI API returned {response.StatusCode}");
|
||||
}
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync(ct);
|
||||
return ParseOpenAiResponse(responseJson);
|
||||
}
|
||||
|
||||
private static string BuildPrompt(UrlAnalysis analysis)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Generate Google Ads copy for this business.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"URL: {analysis.Url}");
|
||||
sb.AppendLine($"Title: {analysis.Title}");
|
||||
sb.AppendLine($"Description: {analysis.MetaDescription}");
|
||||
|
||||
if (analysis.Headings.Count > 0)
|
||||
sb.AppendLine($"Headings: {string.Join(", ", analysis.Headings)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(analysis.BodySnippet))
|
||||
sb.AppendLine($"Content: {analysis.BodySnippet}");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Requirements:");
|
||||
sb.AppendLine("- 10 headlines, each MAXIMUM 30 characters");
|
||||
sb.AppendLine("- 4 descriptions, each MAXIMUM 90 characters");
|
||||
sb.AppendLine("- Headlines should be punchy and action-oriented");
|
||||
sb.AppendLine("- Descriptions should expand on value and include a call to action");
|
||||
sb.AppendLine("- Do NOT use excessive punctuation or ALL CAPS");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Return JSON only, no markdown:");
|
||||
sb.AppendLine("""{"headlines":["..."],"descriptions":["..."]}""");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private (List<TextAsset>, List<TextAsset>, string) ParseOpenAiResponse(string responseJson)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseJson);
|
||||
|
||||
// Extract the content from OpenAI's response structure
|
||||
var messageContent = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString() ?? "{}";
|
||||
|
||||
// Strip markdown fences if present
|
||||
messageContent = messageContent
|
||||
.Replace("```json", "")
|
||||
.Replace("```", "")
|
||||
.Trim();
|
||||
|
||||
using var parsed = JsonDocument.Parse(messageContent);
|
||||
|
||||
var headlines = new List<TextAsset>();
|
||||
var descriptions = new List<TextAsset>();
|
||||
|
||||
if (parsed.RootElement.TryGetProperty("headlines", out var hArray))
|
||||
{
|
||||
foreach (var h in hArray.EnumerateArray())
|
||||
{
|
||||
var text = h.GetString() ?? "";
|
||||
headlines.Add(ValidateAsset("headline", text, MaxHeadlineChars));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.RootElement.TryGetProperty("descriptions", out var dArray))
|
||||
{
|
||||
foreach (var d in dArray.EnumerateArray())
|
||||
{
|
||||
var text = d.GetString() ?? "";
|
||||
descriptions.Add(ValidateAsset("description", text, MaxDescriptionChars));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[CopyGen] OpenAI returned {H} headlines, {D} descriptions",
|
||||
headlines.Count, descriptions.Count);
|
||||
|
||||
return (headlines, descriptions, "openai");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Emulated
|
||||
|
||||
private (List<TextAsset>, List<TextAsset>, string) EmulateGeneration(UrlAnalysis analysis)
|
||||
{
|
||||
_logger.LogInformation("[CopyGen] Emulated generation for {Url}", analysis.Url);
|
||||
|
||||
var businessName = analysis.Title?.Split('-', '|', '–')[0].Trim() ?? "Our Business";
|
||||
if (businessName.Length > 20) businessName = businessName[..20].Trim();
|
||||
|
||||
var headlines = new List<string>
|
||||
{
|
||||
$"{businessName} Near You",
|
||||
$"Visit {businessName} Today",
|
||||
"Quality You Can Trust",
|
||||
"Get Started Today",
|
||||
"See Our Services",
|
||||
$"Discover {businessName}",
|
||||
"Book an Appointment",
|
||||
"Free Consultation",
|
||||
"Top-Rated Service",
|
||||
"Limited Time Offer"
|
||||
}
|
||||
.Select(h => ValidateAsset("headline", h, MaxHeadlineChars))
|
||||
.ToList();
|
||||
|
||||
var descriptions = new List<string>
|
||||
{
|
||||
$"{businessName} delivers quality products and services. Visit us today and see the difference.",
|
||||
"Trusted by thousands of customers. Get a free quote and experience our commitment to excellence.",
|
||||
"Looking for reliable service? We offer competitive pricing and a satisfaction guarantee.",
|
||||
"Join our happy customers today. Professional service, fair prices, and results that speak."
|
||||
}
|
||||
.Select(d => ValidateAsset("description", d, MaxDescriptionChars))
|
||||
.ToList();
|
||||
|
||||
return (headlines, descriptions, "emulated");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation
|
||||
|
||||
/// <summary>
|
||||
/// Validate and truncate asset text to meet Google Ads character limits.
|
||||
/// </summary>
|
||||
private static TextAsset ValidateAsset(string type, string text, int maxChars)
|
||||
{
|
||||
text = text.Trim();
|
||||
|
||||
// Truncate if over limit (shouldn't happen often with good prompts)
|
||||
if (text.Length > maxChars)
|
||||
text = text[..(maxChars - 1)].TrimEnd() + "…";
|
||||
|
||||
return new TextAsset
|
||||
{
|
||||
Type = type,
|
||||
Text = text,
|
||||
CharCount = text.Length
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
230
Creative/Services/CreativeService.cs
Normal file
230
Creative/Services/CreativeService.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System.Text.Json;
|
||||
using Creative.Models;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Main creative service - dispatches operations to appropriate handlers.
|
||||
/// Stateless: returns JSON, Gateway handles persistence.
|
||||
/// </summary>
|
||||
public class CreativeService
|
||||
{
|
||||
private readonly ScraperService _scraper;
|
||||
private readonly CopyGeneratorService _copyGen;
|
||||
private readonly ImageGeneratorService _imageGen;
|
||||
private readonly ILogger<CreativeService> _logger;
|
||||
|
||||
public CreativeService(
|
||||
ScraperService scraper,
|
||||
CopyGeneratorService copyGen,
|
||||
ImageGeneratorService imageGen,
|
||||
ILogger<CreativeService> logger)
|
||||
{
|
||||
_scraper = scraper;
|
||||
_copyGen = copyGen;
|
||||
_imageGen = imageGen;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main dispatch method - routes to appropriate operation handler.
|
||||
/// </summary>
|
||||
public async Task<CreativeResponse> ExecuteAsync(CreativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var requestId = request.RequestId ?? Guid.NewGuid().ToString("N");
|
||||
var operation = (request.Operation ?? "").Trim();
|
||||
|
||||
_logger.LogInformation("[Creative] Executing {Operation} | RequestId={RequestId}",
|
||||
operation, requestId);
|
||||
|
||||
try
|
||||
{
|
||||
return operation switch
|
||||
{
|
||||
"Ping" => Ping(requestId),
|
||||
"AnalyzeUrl" => await AnalyzeUrlAsync(request, requestId, ct),
|
||||
"GenerateAssets" => await GenerateAssetsAsync(request, requestId, ct),
|
||||
"GetImages" => await GetImagesAsync(request, requestId, ct),
|
||||
"CreateDraft" => await CreateDraftAsync(request, requestId, ct),
|
||||
_ => CreativeResponse.Fail(requestId, "UNKNOWN_OPERATION",
|
||||
$"Unknown operation: {operation}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Creative] {Operation} failed | RequestId={RequestId}",
|
||||
operation, requestId);
|
||||
return CreativeResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Operations
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Health check.
|
||||
/// </summary>
|
||||
private static CreativeResponse Ping(string requestId)
|
||||
{
|
||||
return CreativeResponse.Success(requestId, new
|
||||
{
|
||||
pong = true,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrape and analyze a URL. Returns structured content.
|
||||
/// Payload: { "url": "https://..." }
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> AnalyzeUrlAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var url = GetPayloadString(request, "url");
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return CreativeResponse.Fail(requestId, "MISSING_URL", "payload.url is required");
|
||||
|
||||
url = NormalizeUrl(url);
|
||||
var analysis = await _scraper.AnalyzeUrlAsync(url, ct);
|
||||
|
||||
return CreativeResponse.Success(requestId, analysis);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate text assets from previously analyzed content.
|
||||
/// Payload: { "analysis": { ... } } (UrlAnalysis object)
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> GenerateAssetsAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var analysisJson = GetPayloadObject(request, "analysis");
|
||||
if (analysisJson == null)
|
||||
return CreativeResponse.Fail(requestId, "MISSING_ANALYSIS",
|
||||
"payload.analysis is required (pass result from AnalyzeUrl)");
|
||||
|
||||
var analysis = JsonSerializer.Deserialize<UrlAnalysis>(analysisJson.Value.GetRawText());
|
||||
if (analysis == null)
|
||||
return CreativeResponse.Fail(requestId, "INVALID_ANALYSIS",
|
||||
"Could not deserialize analysis object");
|
||||
|
||||
var (headlines, descriptions, source) = await _copyGen.GenerateAsync(analysis, ct);
|
||||
|
||||
return CreativeResponse.Success(requestId, new
|
||||
{
|
||||
headlines,
|
||||
descriptions,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get images matching previously analyzed content.
|
||||
/// Payload: { "analysis": { ... } } (UrlAnalysis object)
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> GetImagesAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var analysisJson = GetPayloadObject(request, "analysis");
|
||||
if (analysisJson == null)
|
||||
return CreativeResponse.Fail(requestId, "MISSING_ANALYSIS",
|
||||
"payload.analysis is required (pass result from AnalyzeUrl)");
|
||||
|
||||
var analysis = JsonSerializer.Deserialize<UrlAnalysis>(analysisJson.Value.GetRawText());
|
||||
if (analysis == null)
|
||||
return CreativeResponse.Fail(requestId, "INVALID_ANALYSIS",
|
||||
"Could not deserialize analysis object");
|
||||
|
||||
var (images, source) = await _imageGen.GenerateAsync(analysis, ct);
|
||||
|
||||
return CreativeResponse.Success(requestId, new
|
||||
{
|
||||
images,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full pipeline: scrape URL → generate copy → source images → return campaign draft.
|
||||
/// Payload: { "url": "https://..." }
|
||||
/// Gateway persists the returned draft to tbCreativeDraft.
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> CreateDraftAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var url = GetPayloadString(request, "url");
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return CreativeResponse.Fail(requestId, "MISSING_URL", "payload.url is required");
|
||||
|
||||
url = NormalizeUrl(url);
|
||||
|
||||
// Step 1: Scrape
|
||||
_logger.LogInformation("[Creative] CreateDraft step 1/3: scraping {Url}", url);
|
||||
var analysis = await _scraper.AnalyzeUrlAsync(url, ct);
|
||||
|
||||
// Step 2: Generate copy
|
||||
_logger.LogInformation("[Creative] CreateDraft step 2/3: generating copy");
|
||||
var (headlines, descriptions, copySource) = await _copyGen.GenerateAsync(analysis, ct);
|
||||
|
||||
// Step 3: Source images
|
||||
_logger.LogInformation("[Creative] CreateDraft step 3/3: sourcing images");
|
||||
var (images, imageSource) = await _imageGen.GenerateAsync(analysis, ct);
|
||||
|
||||
// Assemble draft - Gateway will persist this
|
||||
var draftId = Guid.NewGuid().ToString("N")[..12];
|
||||
var draft = new CampaignDraft
|
||||
{
|
||||
DraftId = draftId,
|
||||
Url = url,
|
||||
Analysis = analysis,
|
||||
Headlines = headlines,
|
||||
Descriptions = descriptions,
|
||||
Images = images,
|
||||
Source = copySource,
|
||||
ImageSource = imageSource,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Creative] Draft assembled | DraftId={DraftId} Headlines={H} Descriptions={D} Images={I} CopySource={CS} ImageSource={IS}",
|
||||
draftId, headlines.Count, descriptions.Count, images.Count, copySource, imageSource);
|
||||
|
||||
return CreativeResponse.Success(requestId, draft);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
private static string? GetPayloadString(CreativeRequest request, string key)
|
||||
{
|
||||
if (request.Payload == null) return null;
|
||||
if (!request.Payload.TryGetValue(key, out var value)) return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||
_ => value?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement? GetPayloadObject(CreativeRequest request, string key)
|
||||
{
|
||||
if (request.Payload == null) return null;
|
||||
if (!request.Payload.TryGetValue(key, out var value)) return null;
|
||||
|
||||
if (value is JsonElement je && je.ValueKind == JsonValueKind.Object)
|
||||
return je;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
url = url.Trim();
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
414
Creative/Services/ImageGeneratorService.cs
Normal file
414
Creative/Services/ImageGeneratorService.cs
Normal file
@@ -0,0 +1,414 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Creative.Configuration;
|
||||
using Creative.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sources images for campaign drafts.
|
||||
/// Three providers:
|
||||
/// - emulated: placeholder images (no network calls)
|
||||
/// - unsplash: free stock photos via Unsplash API
|
||||
/// - dalle: AI-generated images via OpenAI DALL-E (requires OpenAI key)
|
||||
///
|
||||
/// Google Ads Responsive Display Ad image specs:
|
||||
/// Landscape (1.91:1): 1200×628 recommended
|
||||
/// Square (1:1): 1200×1200 recommended
|
||||
/// Portrait (4:5): 960×1200 recommended (optional)
|
||||
/// </summary>
|
||||
public class ImageGeneratorService
|
||||
{
|
||||
private readonly CreativeConfig _config;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<ImageGeneratorService> _logger;
|
||||
|
||||
public ImageGeneratorService(
|
||||
IOptions<CreativeConfig> config,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<ImageGeneratorService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get images matching the analyzed content.
|
||||
/// Returns a list of ImageAssets and the provider name used.
|
||||
/// </summary>
|
||||
public async Task<(List<ImageAsset> Images, string Source)>
|
||||
GenerateAsync(UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
var provider = (_config.ImageProvider ?? "emulated").ToLowerInvariant();
|
||||
|
||||
_logger.LogInformation("[ImageGen] Provider={Provider} for {Url}", provider, analysis.Url);
|
||||
|
||||
return provider switch
|
||||
{
|
||||
"unsplash" => await GenerateUnsplashAsync(analysis, ct),
|
||||
"dalle" => await GenerateDalleAsync(analysis, ct),
|
||||
_ => EmulateGeneration(analysis)
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Emulated Provider
|
||||
// ============================================================
|
||||
|
||||
private (List<ImageAsset>, string) EmulateGeneration(UrlAnalysis analysis)
|
||||
{
|
||||
_logger.LogInformation("[ImageGen] Emulated images for {Url}", analysis.Url);
|
||||
|
||||
var keyword = ExtractSearchKeyword(analysis);
|
||||
var images = new List<ImageAsset>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ImageId = $"emu-landscape-{Guid.NewGuid():N}"[..20],
|
||||
Url = $"https://placehold.co/1200x628/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = "landscape",
|
||||
Width = 1200,
|
||||
Height = 628,
|
||||
AltText = $"{keyword} - landscape",
|
||||
Attribution = "Placeholder image"
|
||||
},
|
||||
new()
|
||||
{
|
||||
ImageId = $"emu-square-{Guid.NewGuid():N}"[..20],
|
||||
Url = $"https://placehold.co/1200x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = "square",
|
||||
Width = 1200,
|
||||
Height = 1200,
|
||||
AltText = $"{keyword} - square",
|
||||
Attribution = "Placeholder image"
|
||||
},
|
||||
new()
|
||||
{
|
||||
ImageId = $"emu-portrait-{Guid.NewGuid():N}"[..20],
|
||||
Url = $"https://placehold.co/960x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = "portrait",
|
||||
Width = 960,
|
||||
Height = 1200,
|
||||
AltText = $"{keyword} - portrait",
|
||||
Attribution = "Placeholder image"
|
||||
}
|
||||
};
|
||||
|
||||
return (images.Take(_config.ImageCount).ToList(), "emulated");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Unsplash Provider
|
||||
// ============================================================
|
||||
|
||||
private async Task<(List<ImageAsset>, string)> GenerateUnsplashAsync(
|
||||
UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
var keyword = ExtractSearchKeyword(analysis);
|
||||
_logger.LogInformation("[ImageGen] Unsplash search: '{Keyword}'", keyword);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
// Unsplash supports unauthenticated requests at lower rate limits.
|
||||
// With an access key you get 50 req/hour (free tier).
|
||||
var hasKey = !string.IsNullOrWhiteSpace(_config.UnsplashAccessKey);
|
||||
if (hasKey)
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Client-ID {_config.UnsplashAccessKey}");
|
||||
|
||||
var images = new List<ImageAsset>();
|
||||
var orientations = new[] { "landscape", "squarish", "portrait" };
|
||||
|
||||
foreach (var orientation in orientations.Take(_config.ImageCount))
|
||||
{
|
||||
try
|
||||
{
|
||||
var queryUrl = hasKey
|
||||
? $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1"
|
||||
: $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1";
|
||||
|
||||
var response = await client.GetAsync(queryUrl, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[ImageGen] Unsplash returned {Status} for orientation={Orientation}",
|
||||
response.StatusCode, orientation);
|
||||
|
||||
// If unauthenticated and rate-limited, fall back to source.unsplash.com
|
||||
images.Add(BuildUnsplashFallback(keyword, orientation));
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
var results = parsed.RootElement.GetProperty("results");
|
||||
|
||||
if (results.GetArrayLength() == 0)
|
||||
{
|
||||
_logger.LogInformation("[ImageGen] No Unsplash results for '{Keyword}' {Orientation}",
|
||||
keyword, orientation);
|
||||
images.Add(BuildUnsplashFallback(keyword, orientation));
|
||||
continue;
|
||||
}
|
||||
|
||||
var photo = results[0];
|
||||
var mappedOrientation = orientation == "squarish" ? "square" : orientation;
|
||||
|
||||
images.Add(new ImageAsset
|
||||
{
|
||||
ImageId = photo.GetProperty("id").GetString() ?? $"unsplash-{Guid.NewGuid():N}"[..16],
|
||||
Url = photo.GetProperty("urls").GetProperty("regular").GetString() ?? "",
|
||||
DownloadUrl = photo.GetProperty("urls").GetProperty("full").GetString(),
|
||||
Source = "unsplash",
|
||||
Orientation = mappedOrientation,
|
||||
Width = photo.GetProperty("width").GetInt32(),
|
||||
Height = photo.GetProperty("height").GetInt32(),
|
||||
AltText = photo.GetProperty("alt_description").ValueKind == JsonValueKind.Null
|
||||
? keyword
|
||||
: photo.GetProperty("alt_description").GetString(),
|
||||
Attribution = BuildUnsplashAttribution(photo)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[ImageGen] Unsplash error for {Orientation}, using fallback", orientation);
|
||||
images.Add(BuildUnsplashFallback(keyword, orientation));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[ImageGen] Unsplash returned {Count} images", images.Count);
|
||||
return (images, "unsplash");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback using source.unsplash.com redirect (no API key needed, no rate limit).
|
||||
/// Returns a random photo matching the keyword at the requested dimensions.
|
||||
/// </summary>
|
||||
private static ImageAsset BuildUnsplashFallback(string keyword, string orientation)
|
||||
{
|
||||
var (w, h, mapped) = orientation switch
|
||||
{
|
||||
"landscape" => (1200, 628, "landscape"),
|
||||
"squarish" => (1200, 1200, "square"),
|
||||
"portrait" => (960, 1200, "portrait"),
|
||||
_ => (1200, 628, "landscape")
|
||||
};
|
||||
|
||||
return new ImageAsset
|
||||
{
|
||||
ImageId = $"unsplash-fallback-{Guid.NewGuid():N}"[..24],
|
||||
Url = $"https://source.unsplash.com/{w}x{h}/?{Uri.EscapeDataString(keyword)}",
|
||||
Source = "unsplash",
|
||||
Orientation = mapped,
|
||||
Width = w,
|
||||
Height = h,
|
||||
AltText = keyword,
|
||||
Attribution = "Photo from Unsplash"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildUnsplashAttribution(JsonElement photo)
|
||||
{
|
||||
var userName = "Unknown";
|
||||
var userLink = "";
|
||||
|
||||
if (photo.TryGetProperty("user", out var user))
|
||||
{
|
||||
userName = user.TryGetProperty("name", out var name)
|
||||
? name.GetString() ?? "Unknown"
|
||||
: "Unknown";
|
||||
|
||||
if (user.TryGetProperty("links", out var links) &&
|
||||
links.TryGetProperty("html", out var html))
|
||||
{
|
||||
userLink = html.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// Unsplash TOS requires photographer attribution
|
||||
return string.IsNullOrEmpty(userLink)
|
||||
? $"Photo by {userName} on Unsplash"
|
||||
: $"Photo by {userName} on Unsplash ({userLink})";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DALL-E Provider (stubbed — ready for OpenAI key)
|
||||
// ============================================================
|
||||
|
||||
private async Task<(List<ImageAsset>, string)> GenerateDalleAsync(
|
||||
UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
// Guard: DALL-E requires the OpenAI key
|
||||
if (string.IsNullOrWhiteSpace(_config.OpenAiApiKey))
|
||||
{
|
||||
_logger.LogWarning("[ImageGen] DALL-E requested but no OpenAI key configured, falling back to emulated");
|
||||
return EmulateGeneration(analysis);
|
||||
}
|
||||
|
||||
var keyword = ExtractSearchKeyword(analysis);
|
||||
var prompt = BuildDallePrompt(analysis, keyword);
|
||||
|
||||
_logger.LogInformation("[ImageGen] DALL-E generation: '{Prompt}'", prompt[..Math.Min(80, prompt.Length)]);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds);
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}");
|
||||
|
||||
var images = new List<ImageAsset>();
|
||||
|
||||
// DALL-E 3 supports: 1024x1024, 1792x1024 (landscape), 1024x1792 (portrait)
|
||||
var dalleVariants = new[]
|
||||
{
|
||||
(size: "1792x1024", orientation: "landscape", w: 1792, h: 1024),
|
||||
(size: "1024x1024", orientation: "square", w: 1024, h: 1024),
|
||||
(size: "1024x1792", orientation: "portrait", w: 1024, h: 1792)
|
||||
};
|
||||
|
||||
foreach (var variant in dalleVariants.Take(_config.ImageCount))
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = _config.DalleModel,
|
||||
prompt = prompt,
|
||||
n = 1,
|
||||
size = variant.size,
|
||||
quality = "standard",
|
||||
response_format = "url"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync(
|
||||
"https://api.openai.com/v1/images/generations", content, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("[ImageGen] DALL-E returned {Status}: {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
|
||||
// Fall back to emulated for this orientation
|
||||
images.Add(BuildEmulatedFallback(keyword, variant.orientation,
|
||||
variant.w, variant.h));
|
||||
continue;
|
||||
}
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync(ct);
|
||||
var parsed = JsonDocument.Parse(responseJson);
|
||||
var data = parsed.RootElement.GetProperty("data")[0];
|
||||
|
||||
var imageUrl = data.GetProperty("url").GetString() ?? "";
|
||||
var revisedPrompt = data.TryGetProperty("revised_prompt", out var rp)
|
||||
? rp.GetString() : null;
|
||||
|
||||
images.Add(new ImageAsset
|
||||
{
|
||||
ImageId = $"dalle-{variant.orientation}-{Guid.NewGuid():N}"[..24],
|
||||
Url = imageUrl,
|
||||
Source = "dalle",
|
||||
Orientation = variant.orientation,
|
||||
Width = variant.w,
|
||||
Height = variant.h,
|
||||
AltText = revisedPrompt ?? keyword,
|
||||
Attribution = $"AI-generated image via {_config.DalleModel}"
|
||||
});
|
||||
|
||||
_logger.LogInformation("[ImageGen] DALL-E generated {Orientation} image", variant.orientation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[ImageGen] DALL-E error for {Orientation}, using fallback",
|
||||
variant.orientation);
|
||||
images.Add(BuildEmulatedFallback(keyword, variant.orientation,
|
||||
variant.w, variant.h));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[ImageGen] DALL-E returned {Count} images", images.Count);
|
||||
return (images, "dalle");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a DALL-E prompt from the analysis. Aims for clean,
|
||||
/// professional ad imagery — not artistic or abstract.
|
||||
/// </summary>
|
||||
private static string BuildDallePrompt(UrlAnalysis analysis, string keyword)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("Professional advertising photograph for a ");
|
||||
sb.Append(analysis.InferredCategory ?? "business");
|
||||
sb.Append(" business. ");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(analysis.Title))
|
||||
{
|
||||
var businessName = analysis.Title.Split('-', '|', '–')[0].Trim();
|
||||
sb.Append($"Business: {businessName}. ");
|
||||
}
|
||||
|
||||
sb.Append($"Theme: {keyword}. ");
|
||||
sb.Append("Clean, well-lit, commercial style. ");
|
||||
sb.Append("No text or watermarks. Suitable for Google Ads display.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static ImageAsset BuildEmulatedFallback(string keyword, string orientation, int w, int h)
|
||||
{
|
||||
return new ImageAsset
|
||||
{
|
||||
ImageId = $"fallback-{orientation}-{Guid.NewGuid():N}"[..24],
|
||||
Url = $"https://placehold.co/{w}x{h}/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = orientation,
|
||||
Width = w,
|
||||
Height = h,
|
||||
AltText = $"{keyword} - {orientation}",
|
||||
Attribution = "Placeholder image (provider fallback)"
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Shared Helpers
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Extract a concise search keyword from the analysis.
|
||||
/// Uses category first, then title, then domain.
|
||||
/// </summary>
|
||||
private static string ExtractSearchKeyword(UrlAnalysis analysis)
|
||||
{
|
||||
// Prefer inferred category (e.g., "Pizza", "Soccer")
|
||||
if (!string.IsNullOrWhiteSpace(analysis.InferredCategory))
|
||||
return analysis.InferredCategory;
|
||||
|
||||
// Fall back to first meaningful heading
|
||||
var heading = analysis.Headings?.FirstOrDefault(h => h.Length > 3 && h.Length < 40);
|
||||
if (!string.IsNullOrWhiteSpace(heading))
|
||||
return heading;
|
||||
|
||||
// Fall back to title (cleaned)
|
||||
if (!string.IsNullOrWhiteSpace(analysis.Title))
|
||||
{
|
||||
var title = analysis.Title.Split('-', '|', '–')[0].Trim();
|
||||
return title.Length > 30 ? title[..30] : title;
|
||||
}
|
||||
|
||||
// Last resort: domain name
|
||||
try
|
||||
{
|
||||
var uri = new Uri(analysis.Url.StartsWith("http") ? analysis.Url : $"https://{analysis.Url}");
|
||||
return uri.Host.Replace("www.", "").Split('.')[0];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "business";
|
||||
}
|
||||
}
|
||||
}
|
||||
161
Creative/Services/ScraperService.cs
Normal file
161
Creative/Services/ScraperService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using Creative.Configuration;
|
||||
using Creative.Models;
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes a URL and extracts structured business data.
|
||||
/// Supports emulated mode for development without network calls.
|
||||
/// </summary>
|
||||
public class ScraperService
|
||||
{
|
||||
private readonly CreativeConfig _config;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<ScraperService> _logger;
|
||||
|
||||
public ScraperService(
|
||||
IOptions<CreativeConfig> config,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<ScraperService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyze a URL - scrape and extract structured content.
|
||||
/// </summary>
|
||||
public async Task<UrlAnalysis> AnalyzeUrlAsync(string url, CancellationToken ct)
|
||||
{
|
||||
if (!_config.EnableRealApi)
|
||||
return EmulateAnalysis(url);
|
||||
|
||||
return await ScrapeRealAsync(url, ct);
|
||||
}
|
||||
|
||||
#region Real Implementation
|
||||
|
||||
private async Task<UrlAnalysis> ScrapeRealAsync(string url, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[Scraper] Fetching {Url}", url);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(_config.ScrapeTimeoutSeconds);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (compatible; AdPlatformBot/1.0)");
|
||||
|
||||
var html = await client.GetStringAsync(url, ct);
|
||||
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(html);
|
||||
|
||||
// Extract title
|
||||
var title = doc.DocumentNode
|
||||
.SelectSingleNode("//title")?.InnerText?.Trim();
|
||||
|
||||
// Extract meta description
|
||||
var metaDesc = doc.DocumentNode
|
||||
.SelectSingleNode("//meta[@name='description']")?
|
||||
.GetAttributeValue("content", null)?.Trim();
|
||||
|
||||
// Extract H1-H3 headings
|
||||
var headings = new List<string>();
|
||||
foreach (var tag in new[] { "h1", "h2", "h3" })
|
||||
{
|
||||
var nodes = doc.DocumentNode.SelectNodes($"//{tag}");
|
||||
if (nodes != null)
|
||||
{
|
||||
foreach (var node in nodes.Take(5))
|
||||
{
|
||||
var text = HtmlEntity.DeEntitize(node.InnerText).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
headings.Add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract body text snippet (first meaningful paragraphs)
|
||||
var bodySnippet = ExtractBodySnippet(doc);
|
||||
|
||||
_logger.LogInformation("[Scraper] Extracted: title={Title} headings={Count}",
|
||||
title?.Length > 40 ? title[..40] + "..." : title, headings.Count);
|
||||
|
||||
return new UrlAnalysis
|
||||
{
|
||||
Url = url,
|
||||
Title = title,
|
||||
MetaDescription = metaDesc,
|
||||
Headings = headings,
|
||||
BodySnippet = bodySnippet,
|
||||
InferredCategory = null, // Category inference handled by CopyGenerator
|
||||
ScrapedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractBodySnippet(HtmlDocument doc)
|
||||
{
|
||||
// Remove script/style nodes
|
||||
var removeNodes = doc.DocumentNode.SelectNodes("//script|//style|//nav|//footer|//header");
|
||||
if (removeNodes != null)
|
||||
{
|
||||
foreach (var node in removeNodes)
|
||||
node.Remove();
|
||||
}
|
||||
|
||||
var paragraphs = doc.DocumentNode.SelectNodes("//p");
|
||||
if (paragraphs == null) return null;
|
||||
|
||||
var texts = paragraphs
|
||||
.Select(p => HtmlEntity.DeEntitize(p.InnerText).Trim())
|
||||
.Where(t => t.Length > 30)
|
||||
.Take(3);
|
||||
|
||||
var snippet = string.Join(" ", texts);
|
||||
return snippet.Length > 500 ? snippet[..500] : snippet;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Emulated
|
||||
|
||||
private UrlAnalysis EmulateAnalysis(string url)
|
||||
{
|
||||
_logger.LogInformation("[Scraper] Emulated analysis for {Url}", url);
|
||||
|
||||
// Parse domain for realistic emulated data
|
||||
var domain = "example.com";
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url.StartsWith("http") ? url : $"https://{url}");
|
||||
domain = uri.Host.Replace("www.", "");
|
||||
}
|
||||
catch { /* use default */ }
|
||||
|
||||
var businessName = domain.Split('.')[0];
|
||||
var titleCase = char.ToUpper(businessName[0]) + businessName[1..];
|
||||
|
||||
return new UrlAnalysis
|
||||
{
|
||||
Url = url,
|
||||
Title = $"{titleCase} - Quality Products & Services",
|
||||
MetaDescription = $"{titleCase} offers premium products and services. Visit us today for the best experience.",
|
||||
Headings = new List<string>
|
||||
{
|
||||
$"Welcome to {titleCase}",
|
||||
"Our Services",
|
||||
"Why Choose Us",
|
||||
"Contact Us Today"
|
||||
},
|
||||
BodySnippet = $"{titleCase} has been serving customers with dedication and quality. " +
|
||||
"We offer a wide range of products and services designed to meet your needs. " +
|
||||
"Our team is committed to providing exceptional value and customer satisfaction.",
|
||||
InferredCategory = "Business Services",
|
||||
ScrapedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
8
Creative/appsettings.Development.json
Normal file
8
Creative/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Creative/appsettings.json
Normal file
25
Creative/appsettings.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"InternalKey": "",
|
||||
|
||||
"Creative": {
|
||||
"EnableRealApi": false,
|
||||
"OpenAiApiKey": "",
|
||||
"OpenAiModel": "gpt-4o-mini",
|
||||
"OpenAiMaxTokens": 1000,
|
||||
"ScrapeTimeoutSeconds": 15,
|
||||
"OpenAiTimeoutSeconds": 30,
|
||||
"ImageProvider": "emulated",
|
||||
"UnsplashAccessKey": "",
|
||||
"ImageCount": 3,
|
||||
"DalleModel": "dall-e-3",
|
||||
"DalleSize": "1024x1024"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user