snipe/app/platforms/ebay/adapter.py
pyr0ball eb05be0612
Some checks are pending
CI / API — lint + test (pull_request) Waiting to run
CI / Web — typecheck + test + build (pull_request) Waiting to run
feat: wire Forgejo Actions CI/CD workflows (#22)
- ci.yml: API lint (ruff F+I) + pytest, web vue-tsc + vitest + build
- mirror.yml: push to GitHub (CircuitForgeLLC) + Codeberg (CircuitForge) on main/tags
- release.yml: Docker build → Forgejo registry + release via API; GHCR deferred pending BSL policy (cf-agents#3)
- .cliff.toml: git-cliff changelog config for semver releases
- pyproject.toml: add [dev] extras (pytest, ruff), ruff config
- Fix 45 ruff violations across codebase (import sorting, unused vars, unused imports)
2026-04-06 00:00:28 -07:00

291 lines
13 KiB
Python

"""eBay Browse API adapter."""
from __future__ import annotations
import hashlib
import logging
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from typing import Optional
import requests
log = logging.getLogger(__name__)
_SHOPPING_BASE = "https://open.api.ebay.com/shopping"
# Rate limiting for Shopping API GetUserProfile calls.
# Enrichment is incremental — these caps spread API calls across multiple
# searches rather than bursting on first encounter with a new seller batch.
_SHOPPING_API_MAX_PER_SEARCH = 5 # sellers enriched per search call
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
from app.db.models import Listing, MarketComp, Seller
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_LIMIT = 200 # max items per Browse API page
_INSIGHTS_BASE = {
"production": "https://api.ebay.com/buy/marketplace_insights/v1_beta",
"sandbox": "https://api.sandbox.ebay.com/buy/marketplace_insights/v1_beta",
}
def _build_browse_query(base_query: str, or_groups: list[list[str]], must_exclude: list[str]) -> str:
"""Convert OR groups + exclusions into Browse API boolean query syntax.
Browse API uses SQL-like boolean: AND (implicit), OR (keyword), NOT (keyword).
Parentheses work as grouping operators.
Example: 'GPU (16gb OR 24gb OR 48gb) (nvidia OR rtx OR geforce) NOT "parts only"'
"""
parts = [base_query.strip()]
for group in or_groups:
clean = [t.strip() for t in group if t.strip()]
if len(clean) == 1:
parts.append(clean[0])
elif len(clean) > 1:
parts.append(f"({' OR '.join(clean)})")
for term in must_exclude:
term = term.strip()
if term:
# Use minus syntax (-term / -"phrase") — Browse API's NOT keyword
# over-filters dramatically in practice; minus works like web search negatives.
parts.append(f'-"{term}"' if " " in term else f"-{term}")
return " ".join(p for p in parts if p)
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, shared_store: Store, env: str = "production"):
self._tokens = token_manager
self._store = shared_store
self._env = env
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]:
# Build Browse API boolean query from OR groups + exclusions
browse_q = _build_browse_query(query, getattr(filters, "or_groups", []), filters.must_exclude)
filter_parts: list[str] = []
if filters.max_price:
filter_parts.append(f"price:[..{filters.max_price}],priceCurrency:USD")
if filters.min_price:
filter_parts.append(f"price:[{filters.min_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)}}}")
base_params: dict = {"q": browse_q, "limit": _BROWSE_LIMIT}
if filter_parts:
base_params["filter"] = ",".join(filter_parts)
if filters.category_id:
base_params["category_ids"] = filters.category_id
pages = max(1, filters.pages)
seen_ids: set[str] = set()
listings: list[Listing] = []
sellers_to_save: dict[str, Seller] = {}
for page in range(pages):
params = {**base_params, "offset": page * _BROWSE_LIMIT}
resp = requests.get(
f"{self._browse_base}/item_summary/search",
headers=self._headers(),
params=params,
)
resp.raise_for_status()
data = resp.json()
items = data.get("itemSummaries", [])
if not items:
break # no more results
for item in items:
listing = normalise_listing(item)
if listing.platform_listing_id not in seen_ids:
seen_ids.add(listing.platform_listing_id)
listings.append(listing)
# Extract inline seller data available in item_summary
seller_raw = item.get("seller", {})
if seller_raw.get("username") and seller_raw["username"] not in sellers_to_save:
sellers_to_save[seller_raw["username"]] = normalise_seller(seller_raw)
if not data.get("next"):
break # Browse API paginates via "next" href; absence = last page
if sellers_to_save:
self._store.save_sellers(list(sellers_to_save.values()))
# Enrich sellers missing account_age_days via Shopping API (fast HTTP, no Playwright).
# Capped at _SHOPPING_API_MAX_PER_SEARCH to avoid bursting the daily quota when
# many new sellers appear in a single search batch.
needs_age = [s.platform_seller_id for s in sellers_to_save.values()
if s.account_age_days is None]
if needs_age:
self.enrich_sellers_shopping_api(needs_age[:_SHOPPING_API_MAX_PER_SEARCH])
return listings
def enrich_sellers_shopping_api(self, usernames: list[str]) -> None:
"""Fetch RegistrationDate for sellers via Shopping API GetUserProfile.
Uses app-level Bearer token — no user OAuth required. Silently skips
on rate limit (error 1.21) or any other failure so the search response
is never blocked. BTF scraping remains the fallback for the scraper adapter.
Rate limiting: _SHOPPING_API_INTER_REQUEST_DELAY between calls; sellers
enriched within _SELLER_ENRICH_TTL_HOURS are skipped (account age doesn't
change day to day). Callers should already cap the list length.
"""
token = self._tokens.get_token()
headers = {
"X-EBAY-API-IAF-TOKEN": f"Bearer {token}",
"User-Agent": "Mozilla/5.0",
}
cutoff = datetime.now(timezone.utc) - timedelta(hours=_SELLER_ENRICH_TTL_HOURS)
first = True
for username in usernames:
try:
# Skip recently enriched sellers — account age doesn't change daily.
seller = self._store.get_seller("ebay", username)
if seller and seller.fetched_at:
try:
ft = datetime.fromisoformat(seller.fetched_at.replace("Z", "+00:00"))
if ft.tzinfo is None:
ft = ft.replace(tzinfo=timezone.utc)
if ft > cutoff and seller.account_age_days is not None:
continue
except ValueError:
pass
if not first:
import time as _time
_time.sleep(_SHOPPING_API_INTER_REQUEST_DELAY)
first = False
resp = requests.get(
_SHOPPING_BASE,
headers=headers,
params={
"callname": "GetUserProfile",
"appid": self._tokens.client_id,
"siteid": "0",
"version": "967",
"UserID": username,
"responseencoding": "JSON",
},
timeout=10,
)
data = resp.json()
if data.get("Ack") != "Success":
errors = data.get("Errors", [])
if any(e.get("ErrorCode") == "1.21" for e in errors):
log.debug("Shopping API rate-limited for %s — BTF fallback", username)
continue
reg_date = data.get("User", {}).get("RegistrationDate")
if reg_date:
dt = datetime.fromisoformat(reg_date.replace("Z", "+00:00"))
age_days = (datetime.now(timezone.utc) - dt).days
seller = self._store.get_seller("ebay", username)
if seller:
self._store.save_seller(replace(seller, account_age_days=age_days))
log.debug("Shopping API: %s registered %d days ago", username, age_days)
except Exception as e:
log.debug("Shopping API enrich failed for %s: %s", username, e)
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, pages: int = 1) -> list[Listing]:
query_hash = hashlib.md5(query.encode()).hexdigest()
if self._store.get_market_comp("ebay", query_hash):
return [] # cache hit
prices: list[float] = []
try:
# Marketplace Insights API returns sold/completed items — best source for comps.
# Falls back gracefully to Browse API active listings if the endpoint is
# unavailable (requires buy.marketplace.insights scope).
insights_base = _INSIGHTS_BASE.get(self._env, _INSIGHTS_BASE["production"])
resp = requests.get(
f"{insights_base}/item_summary/search",
headers=self._headers(),
params={"q": query, "limit": 50, "filter": "buyingOptions:{FIXED_PRICE}"},
)
if resp.status_code in (403, 404):
# 403 = scope not granted; 404 = endpoint not available for this app tier.
# Both mean: fall back to active listing prices via Browse API.
log.info("comps api: Marketplace Insights unavailable (%d), falling back to Browse API", resp.status_code)
raise PermissionError("Marketplace Insights not available")
resp.raise_for_status()
items = resp.json().get("itemSummaries", [])
prices = [float(i["lastSoldPrice"]["value"]) for i in items if "lastSoldPrice" in i]
log.info("comps api: Marketplace Insights returned %d items, %d with lastSoldPrice", len(items), len(prices))
except PermissionError:
# Fallback: use active listing prices (less accurate but always available)
try:
resp = requests.get(
f"{self._browse_base}/item_summary/search",
headers=self._headers(),
params={"q": query, "limit": 50, "filter": "buyingOptions:{FIXED_PRICE}"},
)
resp.raise_for_status()
items = resp.json().get("itemSummaries", [])
prices = [float(i["price"]["value"]) for i in items if "price" in i]
log.info("comps api: Browse API fallback returned %d items, %d with price", len(items), len(prices))
except Exception:
log.warning("comps api: Browse API fallback failed for %r", query, exc_info=True)
return []
except Exception:
log.warning("comps api: unexpected error for %r", query, exc_info=True)
return []
if not prices:
log.warning("comps api: 0 valid prices extracted — no comp saved for %r", query)
return []
prices.sort()
n = len(prices)
median = (prices[n // 2 - 1] + prices[n // 2]) / 2 if n % 2 == 0 else prices[n // 2]
self._store.save_market_comp(MarketComp(
platform="ebay",
query_hash=query_hash,
median_price=median,
sample_count=n,
expires_at=(datetime.now(timezone.utc) + timedelta(hours=6)).isoformat(),
))
return []