snipe/web/src/stores/search.ts
pyr0ball f48f8ef80f feat: multi-platform scaffolding — phase 1 (eBay-only, wire complete)
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
2026-05-02 20:09:36 -07:00

413 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 // 0100
account_age_score: number // 020
feedback_count_score: number // 020
feedback_ratio_score: number // 020
price_vs_market_score: number // 020
category_history_score: number // 020
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.01.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,
}
})