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 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;
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user