eBay marketplaces are now polled through eBay's official Buy > Browse API (client-credentials OAuth2) instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari. Browse API calls are tracked per day in a new ebay_api_usage table and capped (default 5000, configurable) on eBay's Pacific-time reset clock, so polling halts before the limit is hit. Credentials live in config.toml [ebay] and are overridable via /settings, which also surfaces the day's running call count. Also carries the server.secure_cookies config plumbing (field, accessor, example) consumed by the following commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
228 lines
6.5 KiB
Go
228 lines
6.5 KiB
Go
// Package ebay is a thin client for eBay's official Buy > Browse API. It
|
|
// handles client-credentials OAuth2 token caching and active-listing search
|
|
// (item_summary/search). It deliberately covers only what Veola needs: a
|
|
// keyword search returning normalized listings. Sold/completed data (the
|
|
// Marketplace Insights API) is not implemented here.
|
|
package ebay
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// endpoints bundles the production/sandbox base URLs for the two APIs used.
|
|
type endpoints struct {
|
|
oauth string
|
|
browse string
|
|
}
|
|
|
|
func endpointsFor(environment string) endpoints {
|
|
if strings.EqualFold(strings.TrimSpace(environment), "sandbox") {
|
|
return endpoints{
|
|
oauth: "https://api.sandbox.ebay.com/identity/v1/oauth2/token",
|
|
browse: "https://api.sandbox.ebay.com/buy/browse/v1",
|
|
}
|
|
}
|
|
return endpoints{
|
|
oauth: "https://api.ebay.com/identity/v1/oauth2/token",
|
|
browse: "https://api.ebay.com/buy/browse/v1",
|
|
}
|
|
}
|
|
|
|
// Client is safe for concurrent use. The application access token is cached
|
|
// in memory and refreshed shortly before it expires.
|
|
type Client struct {
|
|
HTTP *http.Client
|
|
|
|
mu sync.Mutex
|
|
clientID string
|
|
clientSecret string
|
|
ends endpoints
|
|
token string
|
|
tokenExpiry time.Time
|
|
}
|
|
|
|
// New builds a client for the given keyset. environment is "production"
|
|
// (default) or "sandbox". Credentials may be empty; calls then fail fast
|
|
// with a "not configured" error.
|
|
func New(clientID, clientSecret, environment string) *Client {
|
|
return &Client{
|
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
|
clientID: clientID,
|
|
clientSecret: clientSecret,
|
|
ends: endpointsFor(environment),
|
|
}
|
|
}
|
|
|
|
// EnsureCredentials updates the keyset if it changed, discarding any cached
|
|
// token so the next call re-authenticates. The environment is fixed at
|
|
// construction time and is not changed here. Safe to call on every poll.
|
|
func (c *Client) EnsureCredentials(clientID, clientSecret string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if clientID == c.clientID && clientSecret == c.clientSecret {
|
|
return
|
|
}
|
|
c.clientID = clientID
|
|
c.clientSecret = clientSecret
|
|
c.token = ""
|
|
c.tokenExpiry = time.Time{}
|
|
}
|
|
|
|
func (c *Client) accessToken(ctx context.Context) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.clientID == "" || c.clientSecret == "" {
|
|
return "", errors.New("ebay credentials not configured")
|
|
}
|
|
if c.token != "" && time.Now().Before(c.tokenExpiry) {
|
|
return c.token, nil
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("grant_type", "client_credentials")
|
|
form.Set("scope", "https://api.ebay.com/oauth/api_scope")
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ends.oauth, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
basic := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret))
|
|
req.Header.Set("Authorization", "Basic "+basic)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ebay oauth: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("ebay oauth: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
var tr struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
if err := json.Unmarshal(body, &tr); err != nil {
|
|
return "", fmt.Errorf("ebay oauth: decode: %w", err)
|
|
}
|
|
if tr.AccessToken == "" {
|
|
return "", errors.New("ebay oauth: empty access token")
|
|
}
|
|
c.token = tr.AccessToken
|
|
// Refresh a minute early to avoid racing the expiry.
|
|
ttl := time.Duration(tr.ExpiresIn) * time.Second
|
|
if ttl <= time.Minute {
|
|
ttl = time.Minute
|
|
}
|
|
c.tokenExpiry = time.Now().Add(ttl - time.Minute)
|
|
return c.token, nil
|
|
}
|
|
|
|
// browseItemSummary mirrors the subset of the item_summary/search response
|
|
// Veola consumes.
|
|
type browseItemSummary struct {
|
|
ItemID string `json:"itemId"`
|
|
Title string `json:"title"`
|
|
Price struct {
|
|
Value string `json:"value"`
|
|
Currency string `json:"currency"`
|
|
} `json:"price"`
|
|
ItemWebURL string `json:"itemWebUrl"`
|
|
Image struct {
|
|
ImageURL string `json:"imageUrl"`
|
|
} `json:"image"`
|
|
ThumbnailImages []struct {
|
|
ImageURL string `json:"imageUrl"`
|
|
} `json:"thumbnailImages"`
|
|
Seller struct {
|
|
Username string `json:"username"`
|
|
} `json:"seller"`
|
|
}
|
|
|
|
// Search runs one item_summary/search call and returns normalized listings.
|
|
// An empty query is rejected: the Browse API requires a non-empty q.
|
|
func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) {
|
|
query := strings.TrimSpace(p.Query)
|
|
if query == "" {
|
|
return nil, errors.New("ebay search requires a non-empty query")
|
|
}
|
|
token, err := c.accessToken(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
marketplace := p.MarketplaceID
|
|
if marketplace == "" {
|
|
marketplace = "EBAY_US"
|
|
}
|
|
limit := p.Limit
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
|
|
q := url.Values{}
|
|
q.Set("q", query)
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
if f := buyingOptionsFilter(p.ListingType); f != "" {
|
|
q.Set("filter", f)
|
|
}
|
|
reqURL := c.ends.browse + "/item_summary/search?" + q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("X-EBAY-C-MARKETPLACE-ID", marketplace)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ebay browse: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("ebay browse: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
var sr struct {
|
|
ItemSummaries []browseItemSummary `json:"itemSummaries"`
|
|
}
|
|
if err := json.Unmarshal(body, &sr); err != nil {
|
|
return nil, fmt.Errorf("ebay browse: decode: %w", err)
|
|
}
|
|
|
|
out := make([]Listing, 0, len(sr.ItemSummaries))
|
|
for _, s := range sr.ItemSummaries {
|
|
price, _ := strconv.ParseFloat(strings.TrimSpace(s.Price.Value), 64)
|
|
img := s.Image.ImageURL
|
|
if img == "" && len(s.ThumbnailImages) > 0 {
|
|
img = s.ThumbnailImages[0].ImageURL
|
|
}
|
|
store := "ebay"
|
|
if s.Seller.Username != "" {
|
|
store = "ebay (" + s.Seller.Username + ")"
|
|
}
|
|
out = append(out, Listing{
|
|
Title: s.Title,
|
|
Price: price,
|
|
Currency: s.Price.Currency,
|
|
URL: s.ItemWebURL,
|
|
Store: store,
|
|
ImageURL: img,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|