From 1672e215b296d5702e5dddfe3d5b44fe4d9fecb5 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 25 Mar 2026 12:54:30 -0700 Subject: [PATCH] feat: add eBay adapter with Browse API, Seller API, and market comps --- app/platforms/ebay/adapter.py | 98 +++++++++++++++++++++++++ app/platforms/ebay/normaliser.py | 68 +++++++++++++++++ tests/platforms/__init__.py | 0 tests/platforms/test_ebay_normaliser.py | 57 ++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 app/platforms/ebay/adapter.py create mode 100644 app/platforms/ebay/normaliser.py create mode 100644 tests/platforms/__init__.py create mode 100644 tests/platforms/test_ebay_normaliser.py diff --git a/app/platforms/ebay/adapter.py b/app/platforms/ebay/adapter.py new file mode 100644 index 0000000..6fadec0 --- /dev/null +++ b/app/platforms/ebay/adapter.py @@ -0,0 +1,98 @@ +"""eBay Browse API adapter.""" +from __future__ import annotations +import hashlib +from datetime import datetime, timedelta, timezone +from typing import Optional +import requests + +from app.db.models import Listing, Seller, MarketComp +from app.db.store import Store +from app.platforms import PlatformAdapter, SearchFilters +from app.platforms.ebay.auth import EbayTokenManager +from app.platforms.ebay.normaliser import normalise_listing, normalise_seller + +BROWSE_BASE = { + "production": "https://api.ebay.com/buy/browse/v1", + "sandbox": "https://api.sandbox.ebay.com/buy/browse/v1", +} +# Note: seller lookup uses the Browse API with a seller filter, not a separate Seller API. +# The Commerce Identity /user endpoint returns the calling app's own identity (requires +# user OAuth, not app credentials). Seller metadata is extracted from Browse API inline +# seller fields. registrationDate is available in item detail responses via this path. + + +class EbayAdapter(PlatformAdapter): + def __init__(self, token_manager: EbayTokenManager, store: Store, env: str = "production"): + self._tokens = token_manager + self._store = store + self._browse_base = BROWSE_BASE[env] + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self._tokens.get_token()}"} + + def search(self, query: str, filters: SearchFilters) -> list[Listing]: + params: dict = {"q": query, "limit": 50} + filter_parts = [] + if filters.max_price: + filter_parts.append(f"price:[..{filters.max_price}],priceCurrency:USD") + if filters.condition: + cond_map = {"new": "NEW", "used": "USED", "open box": "OPEN_BOX", "for parts": "FOR_PARTS_NOT_WORKING"} + ebay_conds = [cond_map[c] for c in filters.condition if c in cond_map] + if ebay_conds: + filter_parts.append(f"conditions:{{{','.join(ebay_conds)}}}") + if filter_parts: + params["filter"] = ",".join(filter_parts) + + resp = requests.get(f"{self._browse_base}/item_summary/search", + headers=self._headers(), params=params) + resp.raise_for_status() + items = resp.json().get("itemSummaries", []) + return [normalise_listing(item) for item in items] + + def get_seller(self, seller_platform_id: str) -> Optional[Seller]: + cached = self._store.get_seller("ebay", seller_platform_id) + if cached: + return cached + try: + resp = requests.get( + f"{self._browse_base}/item_summary/search", + headers={**self._headers(), "X-EBAY-C-MARKETPLACE-ID": "EBAY_US"}, + params={"seller": seller_platform_id, "limit": 1}, + ) + resp.raise_for_status() + items = resp.json().get("itemSummaries", []) + if not items: + return None + seller = normalise_seller(items[0].get("seller", {})) + self._store.save_seller(seller) + return seller + except Exception: + return None # Caller handles None gracefully (partial score) + + def get_completed_sales(self, query: str) -> list[Listing]: + query_hash = hashlib.md5(query.encode()).hexdigest() + cached = self._store.get_market_comp("ebay", query_hash) + if cached: + return [] # Comp data is used directly; return empty to signal cache hit + + params = {"q": query, "limit": 20, "filter": "buyingOptions:{FIXED_PRICE}"} + try: + resp = requests.get(f"{self._browse_base}/item_summary/search", + headers=self._headers(), params=params) + resp.raise_for_status() + items = resp.json().get("itemSummaries", []) + listings = [normalise_listing(item) for item in items] + if listings: + prices = sorted(l.price for l in listings) + median = prices[len(prices) // 2] + comp = MarketComp( + platform="ebay", + query_hash=query_hash, + median_price=median, + sample_count=len(prices), + expires_at=(datetime.now(timezone.utc) + timedelta(hours=6)).isoformat(), + ) + self._store.save_market_comp(comp) + return listings + except Exception: + return [] diff --git a/app/platforms/ebay/normaliser.py b/app/platforms/ebay/normaliser.py new file mode 100644 index 0000000..ef5f083 --- /dev/null +++ b/app/platforms/ebay/normaliser.py @@ -0,0 +1,68 @@ +"""Convert raw eBay API responses into Snipe domain objects.""" +from __future__ import annotations +import json +from datetime import datetime, timezone +from app.db.models import Listing, Seller + + +def normalise_listing(raw: dict) -> Listing: + price_data = raw.get("price", {}) + photos = [] + if "image" in raw: + photos.append(raw["image"].get("imageUrl", "")) + for img in raw.get("additionalImages", []): + url = img.get("imageUrl", "") + if url and url not in photos: + photos.append(url) + photos = [p for p in photos if p] + + listing_age_days = 0 + created_raw = raw.get("itemCreationDate", "") + if created_raw: + try: + created = datetime.fromisoformat(created_raw.replace("Z", "+00:00")) + listing_age_days = (datetime.now(timezone.utc) - created).days + except ValueError: + pass + + seller = raw.get("seller", {}) + return Listing( + platform="ebay", + platform_listing_id=raw["itemId"], + title=raw.get("title", ""), + price=float(price_data.get("value", 0)), + currency=price_data.get("currency", "USD"), + condition=raw.get("condition", "").lower(), + seller_platform_id=seller.get("username", ""), + url=raw.get("itemWebUrl", ""), + photo_urls=photos, + listing_age_days=listing_age_days, + ) + + +def normalise_seller(raw: dict) -> Seller: + feedback_pct = float(raw.get("feedbackPercentage", "0").strip("%")) / 100.0 + + account_age_days = 0 + reg_date_raw = raw.get("registrationDate", "") + if reg_date_raw: + try: + reg_date = datetime.fromisoformat(reg_date_raw.replace("Z", "+00:00")) + account_age_days = (datetime.now(timezone.utc) - reg_date).days + except ValueError: + pass + + category_history = {} + summary = raw.get("sellerFeedbackSummary", {}) + for entry in summary.get("feedbackByCategory", []): + category_history[entry.get("categorySite", "")] = int(entry.get("count", 0)) + + return Seller( + platform="ebay", + platform_seller_id=raw["username"], + username=raw["username"], + account_age_days=account_age_days, + feedback_count=int(raw.get("feedbackScore", 0)), + feedback_ratio=feedback_pct, + category_history_json=json.dumps(category_history), + ) diff --git a/tests/platforms/__init__.py b/tests/platforms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/platforms/test_ebay_normaliser.py b/tests/platforms/test_ebay_normaliser.py new file mode 100644 index 0000000..ebb75fa --- /dev/null +++ b/tests/platforms/test_ebay_normaliser.py @@ -0,0 +1,57 @@ +import pytest +from app.platforms.ebay.normaliser import normalise_listing, normalise_seller + + +def test_normalise_listing_maps_fields(): + raw = { + "itemId": "v1|12345|0", + "title": "RTX 4090 GPU", + "price": {"value": "950.00", "currency": "USD"}, + "condition": "USED", + "seller": {"username": "techguy", "feedbackScore": 300, "feedbackPercentage": "99.1"}, + "itemWebUrl": "https://ebay.com/itm/12345", + "image": {"imageUrl": "https://i.ebayimg.com/1.jpg"}, + "additionalImages": [{"imageUrl": "https://i.ebayimg.com/2.jpg"}], + "itemCreationDate": "2026-03-20T00:00:00.000Z", + } + listing = normalise_listing(raw) + assert listing.platform == "ebay" + assert listing.platform_listing_id == "v1|12345|0" + assert listing.title == "RTX 4090 GPU" + assert listing.price == 950.0 + assert listing.condition == "used" + assert listing.seller_platform_id == "techguy" + assert "https://i.ebayimg.com/1.jpg" in listing.photo_urls + assert "https://i.ebayimg.com/2.jpg" in listing.photo_urls + + +def test_normalise_listing_handles_missing_images(): + raw = { + "itemId": "v1|999|0", + "title": "GPU", + "price": {"value": "100.00", "currency": "USD"}, + "condition": "NEW", + "seller": {"username": "u"}, + "itemWebUrl": "https://ebay.com/itm/999", + } + listing = normalise_listing(raw) + assert listing.photo_urls == [] + + +def test_normalise_seller_maps_fields(): + raw = { + "username": "techguy", + "feedbackScore": 300, + "feedbackPercentage": "99.1", + "registrationDate": "2020-03-01T00:00:00.000Z", + "sellerFeedbackSummary": { + "feedbackByCategory": [ + {"transactionPercent": "95.0", "categorySite": "ELECTRONICS", "count": "50"} + ] + } + } + seller = normalise_seller(raw) + assert seller.username == "techguy" + assert seller.feedback_count == 300 + assert seller.feedback_ratio == pytest.approx(0.991, abs=0.001) + assert seller.account_age_days > 0