Auction handling: - Capture itemEndDate from eBay Browse API and ending_date from ZenMarket (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket parser (multiple layouts, JST when offset missing). - Per-row "Ends" countdown column + "Ending soon" banner on results pages, live-ticked by flair.js with urgent/critical tinting under 1h/5m. - Backfill ends_at for known auctions when their URL reappears in a poll (dedup hit no longer drops the new end time). - Hide ended auctions from result listings by default via ResultsQuery.ExcludeEnded; rows stay in the DB. Visual flair: - Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift. - htmx swap fade-in via transient .v-just-swapped class. - Count-up animation on dashboard stats. All animations gated behind prefers-reduced-motion. eBay condition + region filters (auctions-style scoping): - items.condition and items.region columns; threaded through item form, CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so cache invalidates when these change. - ebay.SearchParams gains conditionIds and itemLocationCountry filters. Run Now reload + countdown engine: - Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so the entire results view — best price, chart, badge, last polled — reflects the new poll, instead of swapping just one partial. Pre-launch hardening (P1 set): - auth.EqualizeLoginTiming on no-such-user branch. - (*App).serverError centralizes 500s; replaces err.Error() leaks across results/settings/items/users/dashboard handlers. - main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s alongside the existing ReadHeaderTimeout. - noListFS wrapper blocks static directory listings. - Credential fields in settings no longer render value=; blank submission preserves the saved value, with per-field "Saved in settings / Set in config.toml / Not set" status indicator. Misc: - -debug flag wires slog to LevelDebug; raw ZenMarket items logged for format diagnosis. - /healthz public endpoint for reverse-proxy probes. - deploy/veola.service systemd unit template (hardening flags, single ReadWritePaths=/var/lib/veola). - handlers_test.go covers /healthz, setup-gate redirect, auth gate, and /login render with httptest + in-memory sqlite. - best_price_currency on items; templates pick the right symbol per row. - .gitignore now excludes *.log / veola-debug.log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
249 lines
7.2 KiB
Go
249 lines
7.2 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"`
|
|
// itemEndDate is present only on auction-format listings.
|
|
ItemEndDate string `json:"itemEndDate"`
|
|
}
|
|
|
|
// 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))
|
|
// The Browse API "filter" parameter takes a comma-separated list of
|
|
// filter clauses; assemble whichever ones the caller requested.
|
|
var filters []string
|
|
if f := buyingOptionsFilter(p.ListingType); f != "" {
|
|
filters = append(filters, f)
|
|
}
|
|
if f := conditionIDsFilter(p.Condition); f != "" {
|
|
filters = append(filters, f)
|
|
}
|
|
if f := itemLocationFilter(p.Region); f != "" {
|
|
filters = append(filters, f)
|
|
}
|
|
if len(filters) > 0 {
|
|
q.Set("filter", strings.Join(filters, ","))
|
|
}
|
|
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 + ")"
|
|
}
|
|
var endsAt *time.Time
|
|
if s.ItemEndDate != "" {
|
|
if t, err := time.Parse(time.RFC3339, s.ItemEndDate); err == nil {
|
|
endsAt = &t
|
|
}
|
|
}
|
|
out = append(out, Listing{
|
|
Title: s.Title,
|
|
Price: price,
|
|
Currency: s.Price.Currency,
|
|
URL: s.ItemWebURL,
|
|
Store: store,
|
|
ImageURL: img,
|
|
EndsAt: endsAt,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|