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,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
}

View 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;
}
}

View 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";
}
}
}

View 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
}