Skip to content

Commit aa16c0d

Browse files
committed
feat: ebay listing price v1
1 parent b144670 commit aa16c0d

15 files changed

Lines changed: 1893 additions & 18 deletions

api/app_types/ebay_browse.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""eBay Browse API response + aggregated market search shapes."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Literal, NotRequired, TypedDict
6+
7+
8+
class EbayMoney(TypedDict, total=False):
9+
"""Money block as returned by eBay (``value`` is a numeric string)."""
10+
11+
value: str
12+
currency: str
13+
14+
15+
class EbayImage(TypedDict, total=False):
16+
imageUrl: str
17+
18+
19+
class EbaySeller(TypedDict, total=False):
20+
username: str
21+
feedbackPercentage: str
22+
feedbackScore: int
23+
24+
25+
class EbayItemLocation(TypedDict, total=False):
26+
country: str
27+
postalCode: str
28+
stateOrProvince: str
29+
30+
31+
class EbayCategoryInfo(TypedDict, total=False):
32+
categoryId: str
33+
categoryName: str
34+
35+
36+
class EbayItemSummary(TypedDict, total=False):
37+
"""One item summary block from ``/buy/browse/v1/item_summary/search``."""
38+
39+
itemId: str
40+
title: str
41+
price: EbayMoney
42+
condition: str
43+
conditionId: str
44+
itemWebUrl: str
45+
itemLocation: EbayItemLocation
46+
seller: EbaySeller
47+
image: EbayImage
48+
thumbnailImages: list[EbayImage]
49+
additionalImages: list[EbayImage]
50+
categories: list[EbayCategoryInfo]
51+
buyingOptions: list[str]
52+
shippingOptions: list[dict]
53+
itemCreationDate: str
54+
itemEndDate: str
55+
56+
57+
class EbayBrowseResponse(TypedDict, total=False):
58+
"""Full response of ``/buy/browse/v1/item_summary/search``."""
59+
60+
itemSummaries: list[EbayItemSummary]
61+
total: int
62+
next: str
63+
limit: int
64+
offset: int
65+
warnings: list[dict]
66+
67+
68+
# --- Aggregated output for the GoupixDex internal API -----------------------
69+
70+
71+
GradedFilter = Literal["raw", "psa", "cgc", "bgs", "all"]
72+
ConditionFilter = Literal["new", "used", "all"]
73+
SortOrder = Literal["price_asc", "price_desc", "relevance", "newly_listed"]
74+
75+
76+
class MarketGradedInfo(TypedDict):
77+
"""Grading info extracted from item aspects (when present)."""
78+
79+
grader: str
80+
grade: str
81+
82+
83+
class MarketStats(TypedDict):
84+
"""Aggregate price stats over the returned listings (EUR)."""
85+
86+
count: int
87+
min: float | None
88+
median: float | None
89+
max: float | None
90+
avg: float | None
91+
92+
93+
class MarketListing(TypedDict, total=False):
94+
"""Normalized listing returned to the frontend."""
95+
96+
item_id: str
97+
title: str
98+
price_eur: float
99+
currency: str
100+
condition: str
101+
seller_username: str
102+
seller_country: str
103+
seller_feedback_score: int | None
104+
image_url: str | None
105+
listing_url: str
106+
buying_options: list[str]
107+
graded: NotRequired[MarketGradedInfo | None]
108+
109+
110+
class MarketSearchResponse(TypedDict):
111+
"""Top-level response from ``/ebay/market/search``."""
112+
113+
query: str
114+
effective_query: str
115+
marketplace_id: str
116+
period_days: int
117+
filters_applied: dict
118+
stats: MarketStats
119+
items: list[MarketListing]
120+
outliers: list[MarketListing]
121+
outliers_excluded: int
122+
total_matches: int
123+
warnings: list[str]

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from routes import access_requests as access_requests_routes
2121
from routes import articles as articles_routes
2222
from routes import auth as auth_routes
23+
from routes import ebay_market_route
2324
from routes import ebay_route
2425
from routes import pricing_route
2526
from routes import scan as scan_routes
@@ -101,6 +102,7 @@ def health() -> dict[str, str]:
101102
app.include_router(articles_routes.router)
102103
app.include_router(settings_route.router)
103104
app.include_router(ebay_route.router)
105+
app.include_router(ebay_market_route.router)
104106
app.include_router(pricing_route.router)
105107
app.include_router(stats_route.router)
106108
app.include_router(scan_routes.router)

api/routes/ebay_market_route.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Market price lookup on eBay France (public Browse API)."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Annotated
7+
8+
from fastapi import APIRouter, Depends, HTTPException, Query, status
9+
10+
from app_types.ebay_browse import (
11+
ConditionFilter,
12+
GradedFilter,
13+
MarketSearchResponse,
14+
SortOrder,
15+
)
16+
from config import get_settings
17+
from core.deps import get_current_user
18+
from models.user import User
19+
from services.ebay_app_oauth_service import ebay_app_oauth_configured
20+
from services.ebay_browse_service import DEFAULT_LIMIT, MAX_LIMIT, browse_search
21+
from services.ebay_price_aggregator_service import aggregate_prices, partition_outliers
22+
23+
logger = logging.getLogger(__name__)
24+
25+
router = APIRouter(prefix="/ebay/market", tags=["ebay-market"])
26+
27+
#: Hardcoded noise-word list appended to every Browse query. These tokens
28+
#: describe **accessories** ("sleeve", "classeur", …) and are unlikely to
29+
#: appear in a user's legitimate sealed-product or card search.
30+
#:
31+
#: Negative tokens are forwarded to eBay as ``-"sleeve"`` so no matching
32+
#: listing is ever returned — this keeps noise out at the source without
33+
#: relying only on statistical outlier filtering.
34+
_DEFAULT_EXCLUDES: tuple[str, ...] = (
35+
"sleeve",
36+
"sleeves",
37+
"protège-cartes",
38+
"protege-cartes",
39+
"protège carte",
40+
"protege carte",
41+
"étui",
42+
"etui",
43+
"classeur",
44+
"portfolio",
45+
"binder",
46+
"album",
47+
"intercalaire",
48+
"divider",
49+
"toploader",
50+
"top loader",
51+
"penny sleeve",
52+
"playmat",
53+
"tapis de jeu",
54+
"boîte rangement",
55+
"boite rangement",
56+
"storage box",
57+
"pin's",
58+
"pin ",
59+
"badge",
60+
"sticker",
61+
"autocollant",
62+
"poster",
63+
"affiche",
64+
"plush",
65+
"peluche",
66+
"figurine",
67+
)
68+
69+
70+
@router.get("/search", response_model=None)
71+
async def search_market(
72+
_user: Annotated[User, Depends(get_current_user)],
73+
q: Annotated[str, Query(min_length=2, max_length=256)],
74+
period_days: Annotated[int, Query(ge=0, le=365)] = 30,
75+
condition: Annotated[ConditionFilter, Query()] = "new",
76+
graded: Annotated[GradedFilter, Query()] = "all",
77+
sort: Annotated[SortOrder, Query()] = "relevance",
78+
fr_only: Annotated[bool, Query()] = False,
79+
min_price: Annotated[float | None, Query(ge=0)] = None,
80+
max_price: Annotated[float | None, Query(ge=0)] = None,
81+
limit: Annotated[int, Query(ge=1, le=MAX_LIMIT)] = DEFAULT_LIMIT,
82+
) -> MarketSearchResponse:
83+
"""
84+
Search **active** eBay France listings matching ``q`` and return aggregated stats.
85+
86+
Authentication uses the OAuth Client Credentials flow (application token):
87+
no user eBay connection required.
88+
89+
Noise-word exclusions (sleeves, étuis, classeurs, …) and statistical
90+
outlier filtering are applied **server-side** by default — both are
91+
relative to the median price, so legitimate low-value cards remain visible.
92+
"""
93+
app = get_settings()
94+
if not ebay_app_oauth_configured(app):
95+
raise HTTPException(
96+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
97+
detail="eBay app credentials not configured on the server (EBAY_CLIENT_ID, EBAY_CLIENT_SECRET).",
98+
)
99+
exclude_list = list(_DEFAULT_EXCLUDES)
100+
try:
101+
raw_listings, total, warnings, effective_q = await browse_search(
102+
q=q.strip(),
103+
period_days=period_days,
104+
condition=condition,
105+
graded=graded,
106+
sort=sort,
107+
min_price=min_price,
108+
max_price=max_price,
109+
fr_only=fr_only,
110+
limit=limit,
111+
exclude_keywords=exclude_list,
112+
app=app,
113+
)
114+
except RuntimeError as exc:
115+
raise HTTPException(status_code=500, detail=str(exc)) from exc
116+
except Exception as exc: # httpx raises HTTPStatusError etc.
117+
logger.warning("eBay Browse search failed: %s", exc)
118+
raise HTTPException(
119+
status_code=status.HTTP_502_BAD_GATEWAY,
120+
detail=f"eBay Browse API error: {exc}",
121+
) from exc
122+
123+
kept, outliers = partition_outliers(raw_listings)
124+
stats = aggregate_prices(kept)
125+
return {
126+
"query": q.strip(),
127+
"effective_query": effective_q,
128+
"marketplace_id": "EBAY_FR",
129+
"period_days": period_days,
130+
"filters_applied": {
131+
"condition": condition,
132+
"graded": graded,
133+
"sort": sort,
134+
"fr_only": fr_only,
135+
"min_price": min_price,
136+
"max_price": max_price,
137+
"limit": limit,
138+
"exclude_keywords": exclude_list,
139+
"exclude_outliers": True,
140+
},
141+
"stats": stats,
142+
"items": kept,
143+
"outliers": outliers,
144+
"outliers_excluded": len(outliers),
145+
"total_matches": total,
146+
"warnings": warnings,
147+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""
2+
eBay OAuth 2.0 **Client Credentials** flow (application token).
3+
4+
Used for server-to-server APIs that don't need a user context, such as the
5+
public Browse API (``/buy/browse/v1``). The returned token is cached in-memory
6+
until close to expiry to avoid hammering the token endpoint.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import base64
13+
import datetime as dt
14+
import logging
15+
from typing import Any
16+
17+
import httpx
18+
19+
from config import AppSettings, get_settings
20+
21+
logger = logging.getLogger(__name__)
22+
23+
#: Scope needed for the public Browse API (read-only marketplace search).
24+
BROWSE_API_SCOPE = "https://api.ebay.com/oauth/api_scope"
25+
26+
#: Renew the token slightly before expiry to avoid mid-request invalidation.
27+
_EXPIRY_SKEW_SECONDS = 120
28+
29+
_cached_token: str | None = None
30+
_cached_expires_at: dt.datetime | None = None
31+
_cache_lock = asyncio.Lock()
32+
33+
34+
def _token_url(app: AppSettings) -> str:
35+
host = "api.sandbox.ebay.com" if app.ebay_use_sandbox else "api.ebay.com"
36+
return f"https://{host}/identity/v1/oauth2/token"
37+
38+
39+
def _basic_auth_header(app: AppSettings) -> str:
40+
raw = f"{(app.ebay_client_id or '').strip()}:{(app.ebay_client_secret or '').strip()}"
41+
return base64.b64encode(raw.encode("utf-8")).decode("ascii")
42+
43+
44+
def ebay_app_oauth_configured(app: AppSettings | None = None) -> bool:
45+
s = app or get_settings()
46+
return bool((s.ebay_client_id or "").strip() and (s.ebay_client_secret or "").strip())
47+
48+
49+
async def _request_app_token(app: AppSettings) -> dict[str, Any]:
50+
data = {"grant_type": "client_credentials", "scope": BROWSE_API_SCOPE}
51+
headers = {
52+
"Content-Type": "application/x-www-form-urlencoded",
53+
"Authorization": f"Basic {_basic_auth_header(app)}",
54+
}
55+
async with httpx.AsyncClient(timeout=30.0) as client:
56+
resp = await client.post(_token_url(app), data=data, headers=headers)
57+
if resp.status_code >= 400:
58+
logger.warning(
59+
"eBay app token request failed: %s %s",
60+
resp.status_code,
61+
resp.text[:500],
62+
)
63+
resp.raise_for_status()
64+
return resp.json()
65+
66+
67+
async def get_app_access_token(*, app: AppSettings | None = None, force_refresh: bool = False) -> str:
68+
"""
69+
Return a valid application access token for Browse API.
70+
71+
Caches the token across calls; refreshes a bit before its natural expiry.
72+
73+
Raises:
74+
RuntimeError: if client id/secret are not configured in the environment.
75+
httpx.HTTPStatusError: if eBay rejected the token request.
76+
"""
77+
global _cached_token, _cached_expires_at # noqa: PLW0603
78+
s = app or get_settings()
79+
if not ebay_app_oauth_configured(s):
80+
raise RuntimeError(
81+
"ebay_app_oauth_not_configured: set EBAY_CLIENT_ID and EBAY_CLIENT_SECRET in the environment.",
82+
)
83+
now = dt.datetime.now(dt.UTC)
84+
if not force_refresh and _cached_token and _cached_expires_at and _cached_expires_at > now:
85+
return _cached_token
86+
87+
async with _cache_lock:
88+
if not force_refresh and _cached_token and _cached_expires_at and _cached_expires_at > now:
89+
return _cached_token
90+
payload = await _request_app_token(s)
91+
access = str(payload.get("access_token") or "").strip()
92+
if not access:
93+
raise RuntimeError("eBay app token response is missing ``access_token``.")
94+
expires_in = int(payload.get("expires_in") or 7200)
95+
_cached_token = access
96+
_cached_expires_at = now + dt.timedelta(seconds=max(60, expires_in - _EXPIRY_SKEW_SECONDS))
97+
return access
98+
99+
100+
def invalidate_cached_app_token() -> None:
101+
"""Forget the cached token (useful after a 401)."""
102+
global _cached_token, _cached_expires_at # noqa: PLW0603
103+
_cached_token = None
104+
_cached_expires_at = None

0 commit comments

Comments
 (0)