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:
@@ -15,6 +15,10 @@ public class EbayAuthService
|
||||
private readonly string _tokenPath;
|
||||
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 =
|
||||
[
|
||||
"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()
|
||||
{
|
||||
_token = null;
|
||||
|
||||
80
EbayListingTool/Services/EbayPriceResearchService.cs
Normal file
80
EbayListingTool/Services/EbayPriceResearchService.cs
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public partial class MainWindow : MetroWindow
|
||||
private readonly AiAssistantService _aiService;
|
||||
private readonly BulkImportService _bulkService;
|
||||
private readonly SavedListingsService _savedService;
|
||||
private readonly EbayPriceResearchService _priceService;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -26,9 +27,10 @@ public partial class MainWindow : MetroWindow
|
||||
_aiService = new AiAssistantService(config);
|
||||
_bulkService = new BulkImportService();
|
||||
_savedService = new SavedListingsService();
|
||||
_priceService = new EbayPriceResearchService(_auth);
|
||||
|
||||
// Photo Analysis tab — no eBay needed
|
||||
PhotoView.Initialise(_aiService, _savedService);
|
||||
PhotoView.Initialise(_aiService, _savedService, _priceService);
|
||||
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
|
||||
|
||||
// Saved Listings tab
|
||||
|
||||
@@ -490,6 +490,18 @@
|
||||
StringFormat="F2" Interval="0.5"/>
|
||||
</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 -->
|
||||
<TextBlock x:Name="PriceReasoningText"
|
||||
FontSize="11" FontStyle="Italic"
|
||||
|
||||
@@ -14,6 +14,7 @@ public partial class PhotoAnalysisView : UserControl
|
||||
{
|
||||
private AiAssistantService? _aiService;
|
||||
private SavedListingsService? _savedService;
|
||||
private EbayPriceResearchService? _priceService;
|
||||
private List<string> _currentImagePaths = new();
|
||||
private PhotoAnalysisResult? _lastResult;
|
||||
private int _activePhotoIndex = 0;
|
||||
@@ -50,10 +51,12 @@ public partial class PhotoAnalysisView : UserControl
|
||||
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
|
||||
}
|
||||
|
||||
public void Initialise(AiAssistantService aiService, SavedListingsService savedService)
|
||||
public void Initialise(AiAssistantService aiService, SavedListingsService savedService,
|
||||
EbayPriceResearchService priceService)
|
||||
{
|
||||
_aiService = aiService;
|
||||
_savedService = savedService;
|
||||
_priceService = priceService;
|
||||
}
|
||||
|
||||
// ---- Photo clip geometry sync ----
|
||||
@@ -204,6 +207,8 @@ public partial class PhotoAnalysisView : UserControl
|
||||
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
|
||||
_lastResult = result;
|
||||
ShowResults(result);
|
||||
// Fire live price lookup in background — updates price display when ready
|
||||
_ = UpdateLivePricesAsync(result.Title);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -344,11 +349,58 @@ public partial class PhotoAnalysisView : UserControl
|
||||
TitleBox.Text = r.Title;
|
||||
DescriptionBox.Text = r.Description;
|
||||
|
||||
// Reset live price row until lookup completes
|
||||
LivePriceRow.Visibility = Visibility.Collapsed;
|
||||
|
||||
// Animate results in
|
||||
var sb = (Storyboard)FindResource("ResultsReveal");
|
||||
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)
|
||||
{
|
||||
var len = TitleBox.Text.Length;
|
||||
|
||||
Reference in New Issue
Block a user