Backend:
- app/platforms/__init__.py: add SUPPORTED_PLATFORMS frozenset (single
source of truth for platform validation); add must_include_mode and
adapter fields to SearchFilters dataclass
- api/main.py: add platform: str = Query("ebay") to both /api/search
and /api/search/async; validate against SUPPORTED_PLATFORMS (422 on
unknown platform); thread platform into structured log lines; document
Phase 2 registry extension point in _make_adapter
Frontend:
- SearchView.vue: platform tab strip (eBay active, Mercari + Poshmark
disabled with "soon" badge) above search bar; eBay-specific controls
(category select, data source, pages, keywords) hidden when platform
!== 'ebay'; platform passed to SearchProgress
- search.ts: platform?: string added to SearchFilters; included in
async search params when non-eBay
- SearchProgress.vue: platform prop + PLATFORM_LABELS map; status line
reads "Searching eBay for…" / "Searching Mercari for…" dynamically
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
|
||
// ── Domain types (mirror app/db/models.py) ───────────────────────────────────
|
||
|
||
export interface Listing {
|
||
id: number | null
|
||
platform: string
|
||
platform_listing_id: string
|
||
title: string
|
||
price: number
|
||
currency: string
|
||
condition: string
|
||
seller_platform_id: string
|
||
url: string
|
||
photo_urls: string[]
|
||
listing_age_days: number
|
||
buying_format: 'fixed_price' | 'auction' | 'best_offer'
|
||
ends_at: string | null
|
||
fetched_at: string | null
|
||
trust_score_id: number | null
|
||
}
|
||
|
||
export interface TrustScore {
|
||
id: number | null
|
||
listing_id: number
|
||
composite_score: number // 0–100
|
||
account_age_score: number // 0–20
|
||
feedback_count_score: number // 0–20
|
||
feedback_ratio_score: number // 0–20
|
||
price_vs_market_score: number // 0–20
|
||
category_history_score: number // 0–20
|
||
photo_hash_duplicate: boolean
|
||
photo_analysis_json: string | null
|
||
red_flags_json: string // JSON array of flag strings
|
||
score_is_partial: boolean
|
||
scored_at: string | null
|
||
}
|
||
|
||
export interface Seller {
|
||
id: number | null
|
||
platform: string
|
||
platform_seller_id: string
|
||
username: string
|
||
account_age_days: number | null
|
||
feedback_count: number
|
||
feedback_ratio: number // 0.0–1.0
|
||
category_history_json: string
|
||
fetched_at: string | null
|
||
}
|
||
|
||
export type MustIncludeMode = 'all' | 'any' | 'groups'
|
||
|
||
export interface SavedSearch {
|
||
id: number
|
||
name: string
|
||
query: string
|
||
platform: string
|
||
filters_json: string // JSON blob of SearchFilters subset
|
||
created_at: string | null
|
||
last_run_at: string | null
|
||
// Monitor settings (migration 014)
|
||
monitor_enabled: boolean
|
||
poll_interval_min: number
|
||
min_trust_score: number
|
||
last_checked_at: string | null
|
||
}
|
||
|
||
export interface SearchParamsResult {
|
||
base_query: string
|
||
must_include_mode: string
|
||
must_include: string
|
||
must_exclude: string
|
||
max_price: number | null
|
||
min_price: number | null
|
||
condition: string[]
|
||
category_id: string | null
|
||
explanation: string
|
||
}
|
||
|
||
export interface SearchFilters {
|
||
minTrustScore?: number
|
||
minPrice?: number
|
||
maxPrice?: number
|
||
conditions?: string[]
|
||
minAccountAgeDays?: number
|
||
minFeedbackCount?: number
|
||
minFeedbackRatio?: number
|
||
hideNewAccounts?: boolean
|
||
hideSuspiciousPrice?: boolean
|
||
hideDuplicatePhotos?: boolean
|
||
hideScratchDent?: boolean
|
||
hideLongOnMarket?: boolean
|
||
hidePriceDrop?: boolean
|
||
pages?: number // number of eBay result pages to fetch (48 listings/page, default 1)
|
||
mustInclude?: string // term string; client-side title filter; semantics set by mustIncludeMode
|
||
mustIncludeMode?: MustIncludeMode // 'all' = AND, 'any' = OR, 'groups' = CNF (pipe = OR within group)
|
||
mustExclude?: string // comma-separated; forwarded to eBay -term AND client-side
|
||
categoryId?: string // eBay category ID (e.g. "27386" = Graphics/Video Cards)
|
||
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
|
||
platform?: string // target platform; defaults to 'ebay' when omitted
|
||
}
|
||
|
||
// ── Session cache ─────────────────────────────────────────────────────────────
|
||
|
||
const CACHE_KEY = 'snipe:search-cache'
|
||
|
||
function saveCache(data: {
|
||
query: string
|
||
results: Listing[]
|
||
trustScores: Record<string, TrustScore>
|
||
sellers: Record<string, Seller>
|
||
marketPrice: number | null
|
||
adapterUsed: 'api' | 'scraper' | null
|
||
}) {
|
||
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(data)) } catch { /* quota */ }
|
||
}
|
||
|
||
function loadCache() {
|
||
try {
|
||
const raw = sessionStorage.getItem(CACHE_KEY)
|
||
return raw ? JSON.parse(raw) : null
|
||
} catch { return null }
|
||
}
|
||
|
||
// ── Store ────────────────────────────────────────────────────────────────────
|
||
|
||
export const useSearchStore = defineStore('search', () => {
|
||
const cached = loadCache()
|
||
|
||
const query = ref<string>(cached?.query ?? '')
|
||
const results = ref<Listing[]>(cached?.results ?? [])
|
||
const trustScores = ref<Map<string, TrustScore>>(
|
||
cached ? new Map(Object.entries(cached.trustScores ?? {})) : new Map()
|
||
)
|
||
const sellers = ref<Map<string, Seller>>(
|
||
cached ? new Map(Object.entries(cached.sellers ?? {})) : new Map()
|
||
)
|
||
const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
|
||
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
|
||
const filters = ref<SearchFilters>({})
|
||
const affiliateActive = ref<boolean>(false)
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const enriching = ref(false) // true while SSE stream is open
|
||
|
||
let _abort: AbortController | null = null
|
||
let _sse: EventSource | null = null
|
||
|
||
function cancelSearch() {
|
||
_abort?.abort()
|
||
_abort = null
|
||
loading.value = false
|
||
closeUpdates()
|
||
}
|
||
|
||
async function search(q: string, filters: SearchFilters = {}) {
|
||
// Cancel any in-flight search before starting a new one
|
||
_abort?.abort()
|
||
_abort = new AbortController()
|
||
const signal = _abort.signal
|
||
|
||
query.value = q
|
||
loading.value = true
|
||
error.value = null
|
||
|
||
try {
|
||
// VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite)
|
||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||
const params = new URLSearchParams({ q })
|
||
// v-model.number sends empty string when a number input is cleared — guard against that
|
||
const maxPrice = Number(filters.maxPrice)
|
||
const minPrice = Number(filters.minPrice)
|
||
if (Number.isFinite(maxPrice) && maxPrice > 0) params.set('max_price', String(maxPrice))
|
||
if (Number.isFinite(minPrice) && minPrice > 0) params.set('min_price', String(minPrice))
|
||
if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages))
|
||
if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim())
|
||
if (filters.mustIncludeMode) params.set('must_include_mode', filters.mustIncludeMode)
|
||
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
|
||
if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim())
|
||
if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter)
|
||
if (filters.platform && filters.platform !== 'ebay') params.set('platform', filters.platform)
|
||
|
||
// Use the async endpoint: returns 202 immediately with a session_id, then
|
||
// streams listings + trust scores via SSE as the scrape completes.
|
||
const res = await fetch(`${apiBase}/api/search/async?${params}`, { signal })
|
||
if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`)
|
||
|
||
const data = await res.json() as {
|
||
session_id: string
|
||
status: 'queued'
|
||
}
|
||
|
||
// HTTP 202 received — scraping is underway in the background.
|
||
// Stay in loading state until the first "listings" SSE event arrives.
|
||
// loading.value stays true; enriching tracks the SSE stream being open.
|
||
enriching.value = true
|
||
_openUpdates(data.session_id, apiBase)
|
||
} catch (e) {
|
||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||
// User cancelled — clear loading but don't surface as an error
|
||
results.value = []
|
||
loading.value = false
|
||
} else {
|
||
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||
results.value = []
|
||
loading.value = false
|
||
}
|
||
_abort = null
|
||
}
|
||
// Note: loading.value is NOT set to false here — it stays true until the
|
||
// first "listings" SSE event arrives (see _openUpdates handler below).
|
||
}
|
||
|
||
function closeUpdates() {
|
||
if (_sse) {
|
||
_sse.close()
|
||
_sse = null
|
||
}
|
||
enriching.value = false
|
||
}
|
||
|
||
// Internal type for typed SSE events from the async search endpoint
|
||
type _AsyncListingsEvent = {
|
||
type: 'listings'
|
||
listings: Listing[]
|
||
trust_scores: Record<string, TrustScore>
|
||
sellers: Record<string, Seller>
|
||
market_price: number | null
|
||
adapter_used: 'api' | 'scraper'
|
||
affiliate_active: boolean
|
||
session_id: string
|
||
}
|
||
|
||
type _MarketPriceEvent = {
|
||
type: 'market_price'
|
||
market_price: number | null
|
||
}
|
||
|
||
type _UpdateEvent = {
|
||
type: 'update'
|
||
platform_listing_id: string
|
||
trust_score: TrustScore
|
||
seller: Seller
|
||
market_price: number | null
|
||
}
|
||
|
||
type _LegacyUpdateEvent = {
|
||
platform_listing_id: string
|
||
trust_score: TrustScore
|
||
seller: Record<string, unknown>
|
||
market_price: number | null
|
||
}
|
||
|
||
type _SSEEvent =
|
||
| _AsyncListingsEvent
|
||
| _MarketPriceEvent
|
||
| _UpdateEvent
|
||
| _LegacyUpdateEvent
|
||
|
||
function _openUpdates(sessionId: string, apiBase: string) {
|
||
// Close any pre-existing stream but preserve enriching state — caller sets it.
|
||
if (_sse) {
|
||
_sse.close()
|
||
_sse = null
|
||
}
|
||
|
||
const es = new EventSource(`${apiBase}/api/updates/${sessionId}`)
|
||
_sse = es
|
||
|
||
es.onmessage = (e) => {
|
||
try {
|
||
const update = JSON.parse(e.data) as _SSEEvent
|
||
|
||
if ('type' in update) {
|
||
// Typed events from the async search endpoint
|
||
if (update.type === 'listings') {
|
||
// First batch: hydrate store and transition out of loading state
|
||
results.value = update.listings ?? []
|
||
trustScores.value = new Map(Object.entries(update.trust_scores ?? {}))
|
||
sellers.value = new Map(Object.entries(update.sellers ?? {}))
|
||
marketPrice.value = update.market_price ?? null
|
||
adapterUsed.value = update.adapter_used ?? null
|
||
affiliateActive.value = update.affiliate_active ?? false
|
||
saveCache({
|
||
query: query.value,
|
||
results: results.value,
|
||
trustScores: update.trust_scores ?? {},
|
||
sellers: update.sellers ?? {},
|
||
marketPrice: marketPrice.value,
|
||
adapterUsed: adapterUsed.value,
|
||
})
|
||
// Scrape complete — turn off the initial loading spinner.
|
||
// enriching stays true while enrichment SSE is still open.
|
||
loading.value = false
|
||
} else if (update.type === 'market_price') {
|
||
if (update.market_price != null) {
|
||
marketPrice.value = update.market_price
|
||
}
|
||
} else if (update.type === 'update') {
|
||
// Per-seller enrichment update (same as legacy format but typed)
|
||
if (update.platform_listing_id && update.trust_score) {
|
||
trustScores.value = new Map(trustScores.value)
|
||
trustScores.value.set(update.platform_listing_id, update.trust_score)
|
||
}
|
||
if (update.seller?.platform_seller_id) {
|
||
sellers.value = new Map(sellers.value)
|
||
sellers.value.set(update.seller.platform_seller_id, update.seller)
|
||
}
|
||
if (update.market_price != null) {
|
||
marketPrice.value = update.market_price
|
||
}
|
||
}
|
||
// type: "error" — no special handling; stream will close via 'done'
|
||
} else {
|
||
// Legacy enrichment update (no type field) from synchronous search path
|
||
const legacy = update as _LegacyUpdateEvent
|
||
if (legacy.platform_listing_id && legacy.trust_score) {
|
||
trustScores.value = new Map(trustScores.value)
|
||
trustScores.value.set(legacy.platform_listing_id, legacy.trust_score)
|
||
}
|
||
if (legacy.seller) {
|
||
const s = legacy.seller as Seller
|
||
if (s.platform_seller_id) {
|
||
sellers.value = new Map(sellers.value)
|
||
sellers.value.set(s.platform_seller_id, s)
|
||
}
|
||
}
|
||
if (legacy.market_price != null) {
|
||
marketPrice.value = legacy.market_price
|
||
}
|
||
}
|
||
} catch {
|
||
// malformed event — ignore
|
||
}
|
||
}
|
||
|
||
es.addEventListener('done', () => {
|
||
closeUpdates()
|
||
})
|
||
|
||
es.onerror = () => {
|
||
// If loading is still true (never got a "listings" event), clear it
|
||
loading.value = false
|
||
closeUpdates()
|
||
}
|
||
}
|
||
|
||
async function enrichSeller(sellerUsername: string, listingId: string): Promise<void> {
|
||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||
const params = new URLSearchParams({
|
||
seller: sellerUsername,
|
||
listing_id: listingId,
|
||
query: query.value,
|
||
})
|
||
const res = await fetch(`${apiBase}/api/enrich?${params}`, { method: 'POST' })
|
||
if (!res.ok) throw new Error(`Enrich failed: ${res.status} ${res.statusText}`)
|
||
const data = await res.json() as {
|
||
trust_score: TrustScore | null
|
||
seller: Seller | null
|
||
}
|
||
if (data.trust_score) trustScores.value.set(listingId, data.trust_score)
|
||
if (data.seller) sellers.value.set(sellerUsername, data.seller)
|
||
}
|
||
|
||
function clearResults() {
|
||
results.value = []
|
||
trustScores.value = new Map()
|
||
sellers.value = new Map()
|
||
marketPrice.value = null
|
||
error.value = null
|
||
}
|
||
|
||
function populateFromLLM(params: SearchParamsResult) {
|
||
query.value = params.base_query
|
||
const mode = params.must_include_mode as MustIncludeMode
|
||
filters.value = {
|
||
...filters.value,
|
||
mustInclude: params.must_include,
|
||
mustIncludeMode: mode,
|
||
mustExclude: params.must_exclude,
|
||
maxPrice: params.max_price ?? undefined,
|
||
minPrice: params.min_price ?? undefined,
|
||
conditions: params.condition.length > 0 ? params.condition : undefined,
|
||
categoryId: params.category_id ?? undefined,
|
||
}
|
||
}
|
||
|
||
function getListing(platformListingId: string): Listing | undefined {
|
||
return results.value.find(l => l.platform_listing_id === platformListingId)
|
||
}
|
||
|
||
return {
|
||
query,
|
||
results,
|
||
trustScores,
|
||
sellers,
|
||
marketPrice,
|
||
adapterUsed,
|
||
affiliateActive,
|
||
loading,
|
||
enriching,
|
||
error,
|
||
filters,
|
||
search,
|
||
cancelSearch,
|
||
enrichSeller,
|
||
closeUpdates,
|
||
clearResults,
|
||
populateFromLLM,
|
||
getListing,
|
||
}
|
||
})
|