Initial commit: EbayListingTool WPF application

C# WPF desktop app for creating eBay UK listings with AI-powered
photo analysis. Features: multi-photo vision analysis via OpenRouter
(Claude), local listing save/export, saved listings browser,
single item listing form, bulk import from CSV/Excel, and eBay
OAuth authentication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Foster
2026-04-13 17:33:27 +01:00
commit 9fad0f2ac0
29 changed files with 5908 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using EbayListingTool.Models;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace EbayListingTool.Services;
/// <summary>
/// Calls Claude via OpenRouter (https://openrouter.ai) using the OpenAI-compatible
/// chat completions endpoint. Sign up at openrouter.ai to get an API key.
/// </summary>
public class AiAssistantService
{
private readonly string _apiKey;
private readonly string _model;
private static readonly HttpClient _http = new();
private const string ApiUrl = "https://openrouter.ai/api/v1/chat/completions";
private const string SystemPrompt =
"You are an expert eBay UK seller assistant. You help write compelling, accurate, " +
"keyword-rich listings for eBay.co.uk. Always use British English. " +
"Be honest about item condition. Keep titles under 80 characters. " +
"Descriptions should be clear and informative for buyers.";
public AiAssistantService(IConfiguration config)
{
_apiKey = config["OpenRouter:ApiKey"] ?? "";
_model = config["OpenRouter:Model"] ?? "anthropic/claude-sonnet-4-5";
}
public async Task<string> GenerateTitleAsync(string productName, string condition, string notes = "")
{
var prompt = $"Write a concise eBay UK listing title (MAXIMUM 80 characters, no more) for this item:\n" +
$"Item: {productName}\n" +
$"Condition: {condition}\n" +
(string.IsNullOrWhiteSpace(notes) ? "" : $"Notes: {notes}\n") +
$"\nReturn ONLY the title, nothing else. No quotes, no explanation.";
return await CallAsync(prompt);
}
public async Task<string> WriteDescriptionAsync(string title, string condition, string notes = "")
{
var prompt = $"Write a clear, honest eBay UK product description for this listing:\n" +
$"Title: {title}\n" +
$"Condition: {condition}\n" +
(string.IsNullOrWhiteSpace(notes) ? "" : $"Seller notes: {notes}\n") +
$"\nInclude:\n" +
$"- What the item is and what's included\n" +
$"- Honest condition notes\n" +
$"- A note about postage (dispatched within 1-2 working days)\n" +
$"- Payment via eBay only\n\n" +
$"Use plain text with line breaks. No HTML. UK English. Keep it friendly and professional.\n" +
$"Return ONLY the description text.";
return await CallAsync(prompt);
}
public async Task<string> SuggestPriceAsync(string title, string condition, IEnumerable<decimal>? soldPrices = null)
{
string priceContext = "";
if (soldPrices != null && soldPrices.Any())
{
var prices = soldPrices.Select(p => $"£{p:F2}");
priceContext = $"\nRecent eBay UK sold prices for similar items: {string.Join(", ", prices)}";
}
var prompt = $"Suggest a competitive Buy It Now price in GBP for this eBay UK listing:\n" +
$"Item: {title}\n" +
$"Condition: {condition}" +
priceContext +
$"\n\nRespond in this exact format:\n" +
$"PRICE: [number only, e.g. 29.99]\n" +
$"REASON: [one sentence explaining the price]";
return await CallAsync(prompt);
}
public async Task<string> EnhanceListingAsync(BulkImportRow row)
{
var needsTitle = string.IsNullOrWhiteSpace(row.Title);
var needsDescription = string.IsNullOrWhiteSpace(row.Description);
var needsPrice = string.IsNullOrWhiteSpace(row.Price) || row.Price == "0";
if (!needsTitle && !needsDescription && !needsPrice)
return "No changes needed.";
var prompt = new StringBuilder();
prompt.AppendLine("Fill in the missing fields for this eBay UK listing. Return valid JSON only.");
prompt.AppendLine($"Item info: Title={row.Title}, Condition={row.Condition}, Category={row.CategoryKeyword}");
prompt.AppendLine();
prompt.AppendLine("Return a JSON object with these fields:");
prompt.AppendLine("{");
if (needsTitle) prompt.AppendLine(" \"title\": \"[max 80 chars, keyword-rich eBay title]\",");
if (needsDescription) prompt.AppendLine(" \"description\": \"[clear plain-text description for eBay UK]\",");
if (needsPrice) prompt.AppendLine(" \"price\": \"[suggested price as a number, e.g. 19.99]\"");
prompt.AppendLine("}");
prompt.AppendLine("Return ONLY the JSON object, no other text.");
var json = await CallAsync(prompt.ToString());
try
{
JObject obj;
try
{
obj = JObject.Parse(json.Trim());
}
catch (JsonReaderException)
{
// Claude sometimes wraps JSON in ```json...``` fences
var match = System.Text.RegularExpressions.Regex.Match(
json, @"```(?:json)?\s*(\{[\s\S]*?\})\s*```");
var candidate = match.Success ? match.Groups[1].Value : json.Trim();
obj = JObject.Parse(candidate);
}
if (needsTitle && obj["title"] != null)
row.Title = obj["title"]!.ToString();
if (needsDescription && obj["description"] != null)
row.Description = obj["description"]!.ToString();
if (needsPrice && obj["price"] != null)
row.Price = obj["price"]!.ToString();
return "Enhanced successfully.";
}
catch
{
return $"AI returned unexpected format: {json}";
}
}
/// <summary>Convenience wrapper — analyses a single photo.</summary>
public Task<PhotoAnalysisResult> AnalyseItemFromPhotoAsync(string imagePath)
=> AnalyseItemFromPhotosAsync(new[] { imagePath });
/// <summary>
/// Analyses one or more photos of the same item (up to 4) using Claude vision.
/// All images are included in a single request so the model can use every angle.
/// </summary>
public async Task<PhotoAnalysisResult> AnalyseItemFromPhotosAsync(IEnumerable<string> imagePaths)
{
var paths = imagePaths.Take(4).ToList();
if (paths.Count == 0)
throw new ArgumentException("At least one image path must be provided.", nameof(imagePaths));
// Build base64 data URLs for every image
const long MaxImageBytes = 8 * 1024 * 1024; // E4: reject before base64 to avoid OOM
var dataUrls = new List<string>(paths.Count);
foreach (var path in paths)
{
var fileInfo = new FileInfo(path);
if (fileInfo.Length > MaxImageBytes)
throw new InvalidOperationException(
$"Photo \"{Path.GetFileName(path)}\" is {fileInfo.Length / 1_048_576.0:F0} MB — please use images under 8 MB.");
var imageBytes = await File.ReadAllBytesAsync(path);
var base64 = Convert.ToBase64String(imageBytes);
var ext = Path.GetExtension(path).TrimStart('.').ToLower();
var mimeType = ext switch { "jpg" or "jpeg" => "image/jpeg", "png" => "image/png",
"gif" => "image/gif", "webp" => "image/webp", _ => "image/jpeg" };
dataUrls.Add($"data:{mimeType};base64,{base64}");
}
var multiPhoto = dataUrls.Count > 1;
var photoPrompt = multiPhoto
? "These are multiple photos of the same item from different angles. Use all photos together to identify the item accurately."
: "Analyse this photo of an item.";
var prompt =
$"You are an expert eBay UK seller. {photoPrompt} Return a JSON object " +
"with everything needed to create an eBay UK listing.\n\n" +
"Return ONLY valid JSON — no markdown, no explanation:\n" +
"{\n" +
" \"item_name\": \"full descriptive name of the item\",\n" +
" \"brand\": \"brand name or empty string if unknown\",\n" +
" \"model\": \"model name/number or empty string if unknown\",\n" +
" \"condition_notes\": \"honest assessment of visible condition from the photo(s)\",\n" +
" \"title\": \"eBay UK listing title, max 80 chars, keyword-rich\",\n" +
" \"description\": \"full plain-text eBay UK description including what it is, condition, " +
"what's likely included, postage note (dispatched within 1-2 working days)\",\n" +
" \"price_suggested\": 0.00,\n" +
" \"price_min\": 0.00,\n" +
" \"price_max\": 0.00,\n" +
" \"price_reasoning\": \"one sentence why this price\",\n" +
" \"category_keyword\": \"best eBay category keyword to search\",\n" +
" \"identification_confidence\": \"High, Medium, or Low\",\n" +
" \"confidence_notes\": \"one sentence explaining confidence level, e.g. brand clearly visible on label\"\n" +
"}\n\n" +
"For prices: research realistic eBay UK sold prices in your knowledge. " +
"price_suggested should be a good Buy It Now price. Use GBP numbers only (no £ symbol).";
var json = await CallWithVisionAsync(dataUrls, prompt);
try
{
JObject obj;
try { obj = JObject.Parse(json.Trim()); }
catch (JsonReaderException)
{
var m = System.Text.RegularExpressions.Regex.Match(json, @"```(?:json)?\s*(\{[\s\S]*?\})\s*```");
obj = JObject.Parse(m.Success ? m.Groups[1].Value : json.Trim());
}
return new PhotoAnalysisResult
{
ItemName = obj["item_name"]?.ToString() ?? "",
Brand = obj["brand"]?.ToString() ?? "",
Model = obj["model"]?.ToString() ?? "",
ConditionNotes = obj["condition_notes"]?.ToString() ?? "",
Title = obj["title"]?.ToString() ?? "",
Description = obj["description"]?.ToString() ?? "",
PriceSuggested = obj["price_suggested"]?.Value<decimal>() ?? 0,
PriceMin = obj["price_min"]?.Value<decimal>() ?? 0,
PriceMax = obj["price_max"]?.Value<decimal>() ?? 0,
PriceReasoning = obj["price_reasoning"]?.ToString() ?? "",
CategoryKeyword = obj["category_keyword"]?.ToString() ?? "",
IdentificationConfidence = obj["identification_confidence"]?.ToString() ?? "",
ConfidenceNotes = obj["confidence_notes"]?.ToString() ?? ""
};
}
catch (Exception ex)
{
// E5: don't expose raw API response (may contain sensitive data / confuse users)
throw new InvalidOperationException($"Could not parse AI response: {ex.Message}");
}
}
private async Task<string> CallWithVisionAsync(IEnumerable<string> imageDataUrls, string textPrompt)
{
if (string.IsNullOrEmpty(_apiKey))
throw new InvalidOperationException("OpenRouter API key not configured in appsettings.json.");
// Build content array: one image_url block per image, then a single text block
var contentParts = imageDataUrls
.Select(url => (object)new { type = "image_url", image_url = new { url } })
.Append(new { type = "text", text = textPrompt })
.ToArray();
// Vision request: content is an array of image + text parts
var requestBody = new
{
model = _model,
max_tokens = 1500,
messages = new object[]
{
new { role = "system", content = SystemPrompt },
new
{
role = "user",
content = contentParts
}
}
};
var json = JsonConvert.SerializeObject(requestBody);
using var request = new HttpRequestMessage(HttpMethod.Post, ApiUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
request.Headers.Add("HTTP-Referer", "https://github.com/ebay-listing-tool");
request.Headers.Add("X-Title", "eBay Listing Tool");
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.SendAsync(request);
var responseJson = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"OpenRouter error ({(int)response.StatusCode}): {responseJson}");
var obj = JObject.Parse(responseJson);
return obj["choices"]?[0]?["message"]?["content"]?.ToString()
?? throw new InvalidOperationException("Unexpected response from OpenRouter.");
}
private async Task<string> CallAsync(string userMessage)
{
if (string.IsNullOrEmpty(_apiKey))
throw new InvalidOperationException(
"OpenRouter API key not configured.\n\n" +
"1. Sign up at https://openrouter.ai\n" +
"2. Create an API key\n" +
"3. Add it to appsettings.json under OpenRouter:ApiKey");
var requestBody = new
{
model = _model,
max_tokens = 1024,
messages = new[]
{
new { role = "system", content = SystemPrompt },
new { role = "user", content = userMessage }
}
};
var json = JsonConvert.SerializeObject(requestBody);
using var request = new HttpRequestMessage(HttpMethod.Post, ApiUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
request.Headers.Add("HTTP-Referer", "https://github.com/ebay-listing-tool");
request.Headers.Add("X-Title", "eBay Listing Tool");
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.SendAsync(request);
var responseJson = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"OpenRouter API error ({(int)response.StatusCode}): {responseJson}");
var obj = JObject.Parse(responseJson);
return obj["choices"]?[0]?["message"]?["content"]?.ToString()
?? throw new InvalidOperationException("Unexpected response format from OpenRouter.");
}
}