Add live eBay Browse API price lookup to photo analysis

After photo analysis completes, fires a background request to the
eBay Browse API (app-level auth, no user login needed) to fetch
current Buy It Now prices for similar items on eBay UK.

- EbayAuthService.GetAppTokenAsync(): client credentials OAuth flow,
  cached for the token lifetime
- EbayPriceResearchService: searches /buy/browse/v1/item_summary/search
  filtered to FIXED_PRICE + EBAY_GB, returns min/max/median/suggested
  (suggested = 40th percentile — competitive but not cheapest)
- PhotoAnalysisView: shows spinner + "Checking live eBay UK prices…",
  then updates price badge, range bar and PriceOverride with real data,
  plus a "Based on N live listings (£X – £Y)" label

Silently degrades if eBay credentials are not configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Foster
2026-04-13 18:05:16 +01:00
parent 1bdc8783f9
commit 551bed6814
5 changed files with 195 additions and 4 deletions

View File

@@ -15,6 +15,10 @@ public class EbayAuthService
private readonly string _tokenPath; private readonly string _tokenPath;
private EbayToken? _token; private EbayToken? _token;
// App-level (client credentials) token — no user login needed, used for Browse API
private string? _appToken;
private DateTime _appTokenExpiry = DateTime.MinValue;
private static readonly string[] Scopes = private static readonly string[] Scopes =
[ [
"https://api.ebay.com/oauth/api_scope", "https://api.ebay.com/oauth/api_scope",
@@ -191,6 +195,47 @@ public class EbayAuthService
} }
} }
/// <summary>
/// Returns an application-level OAuth token (client credentials grant).
/// Does NOT require the user to be logged in — uses Client ID + Secret only.
/// Cached for the token lifetime minus a 5-minute safety margin.
/// </summary>
public async Task<string> GetAppTokenAsync()
{
if (_appToken != null && DateTime.UtcNow < _appTokenExpiry)
return _appToken;
if (string.IsNullOrEmpty(_settings.ClientId) || string.IsNullOrEmpty(_settings.ClientSecret))
throw new InvalidOperationException("eBay Client ID / Secret not configured in appsettings.json.");
var tokenUrl = _settings.Sandbox
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
: "https://api.ebay.com/identity/v1/oauth2/token";
using var http = new HttpClient();
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
var body = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["scope"] = "https://api.ebay.com/oauth/api_scope"
});
var response = await http.PostAsync(tokenUrl, body);
var json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"App token request failed: {json}");
var obj = JObject.Parse(json);
_appToken = obj["access_token"]!.ToString();
var expiresIn = obj["expires_in"]?.Value<int>() ?? 7200;
_appTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn - 300); // 5-min buffer
return _appToken;
}
public void Disconnect() public void Disconnect()
{ {
_token = null; _token = null;

View File

@@ -0,0 +1,80 @@
using System.Net.Http.Headers;
using Newtonsoft.Json.Linq;
namespace EbayListingTool.Services;
public class LivePriceResult
{
public List<decimal> Prices { get; init; } = new();
public int Count => Prices.Count;
public decimal Min => Prices.Count > 0 ? Prices.Min() : 0;
public decimal Max => Prices.Count > 0 ? Prices.Max() : 0;
public decimal Median => Prices.Count > 0 ? Percentile(50) : 0;
public decimal Suggested => Prices.Count > 0 ? Percentile(40) : 0; // 40th pct: competitive but not lowest
private decimal Percentile(int pct)
{
var sorted = Prices.OrderBy(p => p).ToList();
int idx = (int)Math.Round((pct / 100.0) * (sorted.Count - 1));
return sorted[Math.Clamp(idx, 0, sorted.Count - 1)];
}
}
/// <summary>
/// Uses the eBay Browse API (app-level token, no user login required) to fetch
/// current Buy It Now prices for similar items on eBay UK.
/// </summary>
public class EbayPriceResearchService
{
private readonly EbayAuthService _auth;
private static readonly HttpClient _http = new();
public EbayPriceResearchService(EbayAuthService auth)
{
_auth = auth;
}
public async Task<LivePriceResult> GetLivePricesAsync(string query, int limit = 20)
{
if (string.IsNullOrWhiteSpace(query))
return new LivePriceResult();
var token = await _auth.GetAppTokenAsync();
var baseUrl = _auth.BaseUrl;
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{baseUrl}/buy/browse/v1/item_summary/search" +
$"?q={Uri.EscapeDataString(query)}" +
$"&filter=buyingOptions%3A%7BFIXED_PRICE%7D" +
$"&limit={limit}" +
$"&sort=price");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
var response = await _http.SendAsync(request);
var json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"eBay Browse API error ({(int)response.StatusCode}): {json}");
var obj = JObject.Parse(json);
var items = obj["itemSummaries"] as JArray ?? new JArray();
var prices = new List<decimal>();
foreach (var item in items)
{
var priceVal = item["price"]?["value"]?.ToString();
var currency = item["price"]?["currency"]?.ToString();
if (currency == "GBP" && decimal.TryParse(priceVal,
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture,
out var p) && p > 0)
{
prices.Add(p);
}
}
return new LivePriceResult { Prices = prices };
}
}

View File

@@ -14,6 +14,7 @@ public partial class MainWindow : MetroWindow
private readonly AiAssistantService _aiService; private readonly AiAssistantService _aiService;
private readonly BulkImportService _bulkService; private readonly BulkImportService _bulkService;
private readonly SavedListingsService _savedService; private readonly SavedListingsService _savedService;
private readonly EbayPriceResearchService _priceService;
public MainWindow() public MainWindow()
{ {
@@ -26,9 +27,10 @@ public partial class MainWindow : MetroWindow
_aiService = new AiAssistantService(config); _aiService = new AiAssistantService(config);
_bulkService = new BulkImportService(); _bulkService = new BulkImportService();
_savedService = new SavedListingsService(); _savedService = new SavedListingsService();
_priceService = new EbayPriceResearchService(_auth);
// Photo Analysis tab — no eBay needed // Photo Analysis tab — no eBay needed
PhotoView.Initialise(_aiService, _savedService); PhotoView.Initialise(_aiService, _savedService, _priceService);
PhotoView.UseDetailsRequested += OnUseDetailsRequested; PhotoView.UseDetailsRequested += OnUseDetailsRequested;
// Saved Listings tab // Saved Listings tab

View File

@@ -490,6 +490,18 @@
StringFormat="F2" Interval="0.5"/> StringFormat="F2" Interval="0.5"/>
</StackPanel> </StackPanel>
<!-- Live eBay price status -->
<StackPanel x:Name="LivePriceRow" Orientation="Horizontal"
Margin="0,6,0,0" Visibility="Collapsed">
<mah:ProgressRing x:Name="LivePriceSpinner"
Width="11" Height="11" Margin="0,0,6,0"/>
<TextBlock x:Name="LivePriceStatus"
FontSize="10"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
</StackPanel>
<!-- Price reasoning --> <!-- Price reasoning -->
<TextBlock x:Name="PriceReasoningText" <TextBlock x:Name="PriceReasoningText"
FontSize="11" FontStyle="Italic" FontSize="11" FontStyle="Italic"

View File

@@ -14,6 +14,7 @@ public partial class PhotoAnalysisView : UserControl
{ {
private AiAssistantService? _aiService; private AiAssistantService? _aiService;
private SavedListingsService? _savedService; private SavedListingsService? _savedService;
private EbayPriceResearchService? _priceService;
private List<string> _currentImagePaths = new(); private List<string> _currentImagePaths = new();
private PhotoAnalysisResult? _lastResult; private PhotoAnalysisResult? _lastResult;
private int _activePhotoIndex = 0; private int _activePhotoIndex = 0;
@@ -50,10 +51,12 @@ public partial class PhotoAnalysisView : UserControl
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged; PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
} }
public void Initialise(AiAssistantService aiService, SavedListingsService savedService) public void Initialise(AiAssistantService aiService, SavedListingsService savedService,
EbayPriceResearchService priceService)
{ {
_aiService = aiService; _aiService = aiService;
_savedService = savedService; _savedService = savedService;
_priceService = priceService;
} }
// ---- Photo clip geometry sync ---- // ---- Photo clip geometry sync ----
@@ -204,6 +207,8 @@ public partial class PhotoAnalysisView : UserControl
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths); var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
_lastResult = result; _lastResult = result;
ShowResults(result); ShowResults(result);
// Fire live price lookup in background — updates price display when ready
_ = UpdateLivePricesAsync(result.Title);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -344,11 +349,58 @@ public partial class PhotoAnalysisView : UserControl
TitleBox.Text = r.Title; TitleBox.Text = r.Title;
DescriptionBox.Text = r.Description; DescriptionBox.Text = r.Description;
// Reset live price row until lookup completes
LivePriceRow.Visibility = Visibility.Collapsed;
// Animate results in // Animate results in
var sb = (Storyboard)FindResource("ResultsReveal"); var sb = (Storyboard)FindResource("ResultsReveal");
sb.Begin(this); sb.Begin(this);
} }
private async Task UpdateLivePricesAsync(string query)
{
if (_priceService == null) return;
// Show spinner
LivePriceRow.Visibility = Visibility.Visible;
LivePriceSpinner.Visibility = Visibility.Visible;
LivePriceStatus.Text = "Checking live eBay UK prices…";
try
{
var live = await _priceService.GetLivePricesAsync(query);
if (live.Count == 0)
{
LivePriceStatus.Text = "No matching live listings found.";
LivePriceSpinner.Visibility = Visibility.Collapsed;
return;
}
// Update range bar with real data
PriceMinText.Text = $"£{live.Min:F2}";
PriceMaxText.Text = $"£{live.Max:F2}";
PriceRangeBar.Visibility = Visibility.Visible;
// Update suggested price to 40th percentile (competitive but not cheapest)
var suggested = live.Suggested;
PriceSuggestedText.Text = $"£{suggested:F2}";
PriceOverride.Value = (double)suggested;
if (_lastResult != null) _lastResult.PriceSuggested = suggested;
// Update status label
LivePriceSpinner.Visibility = Visibility.Collapsed;
LivePriceStatus.Text =
$"Based on {live.Count} live eBay UK listing{(live.Count == 1 ? "" : "s")} " +
$"(range £{live.Min:F2} £{live.Max:F2})";
}
catch (Exception ex)
{
LivePriceSpinner.Visibility = Visibility.Collapsed;
LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}";
}
}
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e) private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
{ {
var len = TitleBox.Text.Length; var len = TitleBox.Text.Length;