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>
381 lines
12 KiB
Go
381 lines
12 KiB
Go
package apify
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// jstLocation is the timezone ZenMarket reports Yahoo Auctions JP end times
|
|
// in when the value lacks an explicit offset. Falls back to UTC if the
|
|
// embedded tzdata lookup fails (main.go imports time/tzdata so in practice
|
|
// this always resolves).
|
|
var jstLocation = func() *time.Location {
|
|
loc, err := time.LoadLocation("Asia/Tokyo")
|
|
if err != nil {
|
|
return time.UTC
|
|
}
|
|
return loc
|
|
}()
|
|
|
|
// yahooEndingDateLayouts is the ordered list of layouts attempted when
|
|
// parsing ZenMarket's `ending_date`. Some entries use Local-as-JST (no
|
|
// offset in the string) — see parseYahooEndingDate for the routing.
|
|
var yahooEndingDateLayouts = []struct {
|
|
layout string
|
|
jst bool // true: parse as JST; false: parse with offset/zone from string
|
|
}{
|
|
{time.RFC3339, false},
|
|
{"2006-01-02T15:04:05", true},
|
|
{"2006-01-02 15:04:05", true},
|
|
{"2006/01/02 15:04:05", true},
|
|
{"2006/01/02 15:04", true},
|
|
{"2006-01-02 15:04", true},
|
|
}
|
|
|
|
// parseYahooEndingDate is a permissive parser for ZenMarket's ending_date
|
|
// field. The actor's output format has shifted over time and isn't documented;
|
|
// rather than wedging on one layout, try the known shapes in order and treat
|
|
// anything without an explicit zone as JST (Yahoo Auctions runs in Japan).
|
|
// Returns nil + logs a warning when nothing parses, so operators see the raw
|
|
// value and can extend this list.
|
|
func parseYahooEndingDate(s string) *time.Time {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
for _, l := range yahooEndingDateLayouts {
|
|
var (
|
|
t time.Time
|
|
err error
|
|
)
|
|
if l.jst {
|
|
t, err = time.ParseInLocation(l.layout, s, jstLocation)
|
|
} else {
|
|
t, err = time.Parse(l.layout, s)
|
|
}
|
|
if err == nil {
|
|
slog.Debug("yahoo ending_date parsed",
|
|
"raw", s, "layout", l.layout, "jst_assumed", l.jst, "parsed", t.UTC().Format(time.RFC3339))
|
|
return &t
|
|
}
|
|
}
|
|
slog.Warn("yahoo ending_date: no layout matched", "raw", s)
|
|
return nil
|
|
}
|
|
|
|
// ActiveListingInput is the input schema for `automation-lab/ebay-scraper`.
|
|
// The actor accepts keyword searches and standard filters; it targets
|
|
// ebay.com only (no per-marketplace routing in the actor itself), so
|
|
// non-US marketplaces won't return useful results with this actor.
|
|
type ActiveListingInput struct {
|
|
SearchQueries []string `json:"searchQueries"`
|
|
MaxProductsPerSearch int `json:"maxProductsPerSearch,omitempty"`
|
|
MaxSearchPages int `json:"maxSearchPages,omitempty"`
|
|
Sort string `json:"sort,omitempty"`
|
|
ListingType string `json:"listingType,omitempty"`
|
|
Condition []string `json:"condition,omitempty"`
|
|
MinPrice *int `json:"minPrice,omitempty"`
|
|
MaxPrice *int `json:"maxPrice,omitempty"`
|
|
ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"`
|
|
}
|
|
|
|
// ProxyConfiguration is the standard apify input block for proxy routing.
|
|
// eBay (and most retail sites) return 403 to datacenter IPs; passing
|
|
// {"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]} works.
|
|
type ProxyConfiguration struct {
|
|
UseApifyProxy bool `json:"useApifyProxy"`
|
|
ApifyProxyGroups []string `json:"apifyProxyGroups,omitempty"`
|
|
ApifyProxyCountry string `json:"apifyProxyCountry,omitempty"`
|
|
}
|
|
|
|
// ActiveListingResult is decoded leniently to handle multiple eBay-scraper
|
|
// actors. delicious_zebu/ebay-product-listing-scraper returns productUrl /
|
|
// imageUrl / numeric price; harvestlab/ebay-scraper used url / price /
|
|
// currency. The decoder coalesces both shapes.
|
|
type ActiveListingResult struct {
|
|
Title string `json:"title"`
|
|
Price any `json:"price"`
|
|
OriginalPrice any `json:"originalPrice"`
|
|
Currency string `json:"currency"`
|
|
URL string `json:"url"`
|
|
ProductURL string `json:"productUrl"`
|
|
Store string `json:"store"`
|
|
ImageURL string `json:"imageUrl"`
|
|
Image string `json:"image"`
|
|
Thumbnail string `json:"thumbnail"`
|
|
Images []string `json:"images"`
|
|
Condition string `json:"condition"`
|
|
ListingType string `json:"listingType"`
|
|
ShippingCost any `json:"shippingCost"`
|
|
ShippingPrice any `json:"shippingPrice"`
|
|
FreeShipping bool `json:"freeShipping"`
|
|
Marketplace string `json:"marketplace"`
|
|
MatchConfidence float64 `json:"matchConfidence"`
|
|
Availability string `json:"availability"`
|
|
WatchersCount int `json:"watchersCount"`
|
|
QuantitySold int `json:"quantitySold"`
|
|
}
|
|
|
|
type SoldListingInput struct {
|
|
Query string `json:"query"`
|
|
Marketplace string `json:"marketplace,omitempty"`
|
|
MaxResults int `json:"maxResults,omitempty"`
|
|
DaysBack int `json:"daysBack,omitempty"`
|
|
ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"`
|
|
}
|
|
|
|
type SoldListingResult struct {
|
|
Title string `json:"title"`
|
|
SoldPrice float64 `json:"soldPrice"`
|
|
Currency string `json:"soldCurrency"`
|
|
SoldAt string `json:"endedAt"`
|
|
Condition string `json:"condition"`
|
|
ListingType string `json:"listingType"`
|
|
ShippingPrice float64 `json:"shippingPrice"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type PriceComparisonInput struct {
|
|
Query string `json:"query,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
MatchStrictness string `json:"matchStrictness,omitempty"`
|
|
ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"`
|
|
}
|
|
|
|
type PriceComparisonResult struct {
|
|
Title string `json:"title"`
|
|
Price float64 `json:"price"`
|
|
Currency string `json:"currency"`
|
|
URL string `json:"url"`
|
|
Store string `json:"store"`
|
|
ImageURL string `json:"imageUrl"`
|
|
Availability string `json:"availability"`
|
|
MatchConfidence float64 `json:"matchConfidence"`
|
|
}
|
|
|
|
// YahooAuctionsJPInput targets meron1122/zenmarket-scraper. ZenMarket is a
|
|
// buyer-proxy for Yahoo Auctions JP; its scraper returns ZenMarket-proxied
|
|
// listing URLs and USD-converted prices.
|
|
type YahooAuctionsJPInput struct {
|
|
SearchTerm string `json:"searchTerm"`
|
|
CategoryID string `json:"categoryID,omitempty"`
|
|
MaxPages int `json:"maxPages,omitempty"`
|
|
MaxRemainingHours int `json:"maxRemainingHours,omitempty"`
|
|
}
|
|
|
|
// MercariJPInput targets cloud9_ai/mercari-scraper. The actor manages its
|
|
// own proxy (Japan datacenter with residential fallback), so we do not send
|
|
// a proxyConfiguration block.
|
|
type MercariJPInput struct {
|
|
SearchKeywords []string `json:"searchKeywords,omitempty"`
|
|
ProductUrls []string `json:"productUrls,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
SortBy string `json:"sortBy,omitempty"`
|
|
PriceMin *int `json:"priceMin,omitempty"`
|
|
PriceMax *int `json:"priceMax,omitempty"`
|
|
ItemCondition string `json:"itemCondition,omitempty"`
|
|
MaxResults int `json:"maxResults,omitempty"`
|
|
}
|
|
|
|
// YahooAuctionsJPResult matches meron1122/zenmarket-scraper output. Prices
|
|
// are USD-converted at the ZenMarket-published rate.
|
|
type YahooAuctionsJPResult struct {
|
|
Name string `json:"name"`
|
|
CurrentPrice any `json:"current_price"`
|
|
Photos []string `json:"photos"`
|
|
URL string `json:"url"`
|
|
EndingDate string `json:"ending_date"`
|
|
}
|
|
|
|
// UnifiedResult is the common shape produced by ParseResults regardless of
|
|
// which actor type returned the data. The scheduler consumes this.
|
|
type UnifiedResult struct {
|
|
Title string
|
|
Price float64
|
|
Currency string
|
|
URL string
|
|
Store string
|
|
ImageURL string
|
|
Source string
|
|
MatchConfidence float64
|
|
OutOfStock bool
|
|
// MatchedQuery records which alias from the item's query list produced
|
|
// this row. Empty for URL-only items or rows from non-search sources.
|
|
MatchedQuery string
|
|
// EndsAt is the auction end time, if known. Auction-format eBay listings
|
|
// and Yahoo Auctions JP listings populate this; fixed-price listings
|
|
// and most Apify scraper outputs leave it nil.
|
|
EndsAt *time.Time
|
|
}
|
|
|
|
// Decode unmarshals a list of raw JSON items into UnifiedResult slices using
|
|
// the shape that matches the given source label.
|
|
func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
|
|
out := make([]UnifiedResult, 0, len(items))
|
|
switch source {
|
|
case SourceActiveEbay, SourcePriceCompare:
|
|
for _, raw := range items {
|
|
var r ActiveListingResult
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
continue
|
|
}
|
|
url := r.URL
|
|
if url == "" {
|
|
url = r.ProductURL
|
|
}
|
|
img := r.ImageURL
|
|
if img == "" {
|
|
img = r.Image
|
|
}
|
|
if img == "" {
|
|
img = r.Thumbnail
|
|
}
|
|
if img == "" && len(r.Images) > 0 {
|
|
img = r.Images[0]
|
|
}
|
|
store := r.Store
|
|
if store == "" {
|
|
store = r.Marketplace
|
|
}
|
|
if store == "" && source == SourceActiveEbay {
|
|
store = "ebay"
|
|
}
|
|
cur := r.Currency
|
|
if cur == "" {
|
|
cur = "USD"
|
|
}
|
|
out = append(out, UnifiedResult{
|
|
Title: r.Title,
|
|
Price: coercePrice(r.Price),
|
|
Currency: cur,
|
|
URL: url,
|
|
Store: store,
|
|
ImageURL: img,
|
|
Source: source,
|
|
MatchConfidence: r.MatchConfidence,
|
|
OutOfStock: isOOS(r.Availability),
|
|
})
|
|
}
|
|
case SourceYahooJP:
|
|
for _, raw := range items {
|
|
slog.Debug("yahoo raw item", "json", string(raw))
|
|
var r YahooAuctionsJPResult
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
slog.Debug("yahoo decode failed", "err", err, "json", string(raw))
|
|
continue
|
|
}
|
|
img := ""
|
|
if len(r.Photos) > 0 {
|
|
img = r.Photos[0]
|
|
}
|
|
out = append(out, UnifiedResult{
|
|
Title: r.Name,
|
|
Price: coercePrice(r.CurrentPrice),
|
|
Currency: "USD",
|
|
URL: r.URL,
|
|
Store: "yahoo-auctions-jp (via zenmarket)",
|
|
ImageURL: img,
|
|
Source: source,
|
|
EndsAt: parseYahooEndingDate(r.EndingDate),
|
|
})
|
|
}
|
|
case SourceMercariJP:
|
|
// Mercari actors vary in shape; accept either price/currentPrice and title/name.
|
|
for _, raw := range items {
|
|
var generic struct {
|
|
Title string `json:"title"`
|
|
Name string `json:"name"`
|
|
Price float64 `json:"price"`
|
|
CurrentPrice float64 `json:"currentPrice"`
|
|
Currency string `json:"currency"`
|
|
URL string `json:"url"`
|
|
ImageURL string `json:"imageUrl"`
|
|
Image string `json:"image"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(raw, &generic); err != nil {
|
|
continue
|
|
}
|
|
title := generic.Title
|
|
if title == "" {
|
|
title = generic.Name
|
|
}
|
|
price := generic.Price
|
|
if price == 0 {
|
|
price = generic.CurrentPrice
|
|
}
|
|
img := generic.ImageURL
|
|
if img == "" {
|
|
img = generic.Image
|
|
}
|
|
cur := generic.Currency
|
|
if cur == "" {
|
|
cur = "JPY"
|
|
}
|
|
out = append(out, UnifiedResult{
|
|
Title: title,
|
|
Price: price,
|
|
Currency: cur,
|
|
URL: generic.URL,
|
|
Store: "mercari-jp",
|
|
ImageURL: img,
|
|
Source: source,
|
|
OutOfStock: isOOS(generic.Status),
|
|
})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
const (
|
|
SourceActiveEbay = "ebay"
|
|
SourcePriceCompare = "price-comparison"
|
|
SourceYahooJP = "yahoo-auctions-jp"
|
|
SourceMercariJP = "mercari-jp"
|
|
SourceSoldEbay = "ebay-sold"
|
|
SourceSoldYahooJP = "yahoo-auctions-jp-sold"
|
|
)
|
|
|
|
// coercePrice accepts a price field that might be a number or a string with
|
|
// currency symbols / commas (e.g. "$24.99", "1,299.00"). Returns 0 on failure
|
|
// so FilterResults can drop the row cleanly.
|
|
func coercePrice(v any) float64 {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return 0
|
|
case float64:
|
|
return x
|
|
case float32:
|
|
return float64(x)
|
|
case int:
|
|
return float64(x)
|
|
case int64:
|
|
return float64(x)
|
|
case string:
|
|
s := strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= '0' && r <= '9', r == '.', r == '-':
|
|
return r
|
|
}
|
|
return -1
|
|
}, x)
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return f
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isOOS(s string) bool {
|
|
switch s {
|
|
case "out_of_stock", "OUT_OF_STOCK", "sold", "SOLD", "ended":
|
|
return true
|
|
}
|
|
return false
|
|
}
|