diff --git a/api/main.py b/api/main.py index 83781f6..75acbb8 100644 --- a/api/main.py +++ b/api/main.py @@ -38,8 +38,20 @@ def health(): return {"status": "ok"} +def _parse_terms(raw: str) -> list[str]: + """Split a comma-separated keyword string into non-empty, stripped terms.""" + return [t.strip() for t in raw.split(",") if t.strip()] + + @app.get("/api/search") -def search(q: str = "", max_price: float = 0, min_price: float = 0, pages: int = 1): +def search( + q: str = "", + max_price: float = 0, + min_price: float = 0, + pages: int = 1, + must_include: str = "", # comma-separated; applied client-side only + must_exclude: str = "", # comma-separated; forwarded to eBay AND applied client-side +): if not q.strip(): return {"listings": [], "trust_scores": {}, "sellers": {}, "market_price": None} @@ -47,6 +59,8 @@ def search(q: str = "", max_price: float = 0, min_price: float = 0, pages: int = max_price=max_price if max_price > 0 else None, min_price=min_price if min_price > 0 else None, pages=max(1, pages), + must_include=_parse_terms(must_include), + must_exclude=_parse_terms(must_exclude), ) # Each adapter gets its own Store (SQLite connection) — required for thread safety. diff --git a/app/platforms/__init__.py b/app/platforms/__init__.py index f9162ee..69b1fae 100644 --- a/app/platforms/__init__.py +++ b/app/platforms/__init__.py @@ -12,7 +12,9 @@ class SearchFilters: min_price: Optional[float] = None condition: Optional[list[str]] = field(default_factory=list) location_radius_km: Optional[int] = None - pages: int = 1 # number of result pages to fetch (48 listings/page) + pages: int = 1 # number of result pages to fetch (48 listings/page) + must_include: list[str] = field(default_factory=list) # client-side title filter + must_exclude: list[str] = field(default_factory=list) # forwarded to eBay -term AND client-side class PlatformAdapter(ABC): diff --git a/app/platforms/ebay/scraper.py b/app/platforms/ebay/scraper.py index 50e33ed..d493805 100644 --- a/app/platforms/ebay/scraper.py +++ b/app/platforms/ebay/scraper.py @@ -302,6 +302,12 @@ class ScrapedEbayAdapter(PlatformAdapter): if codes: base_params["LH_ItemCondition"] = "|".join(codes) + # Append negative keywords to the eBay query — eBay supports "-term" in _nkw natively. + # This reduces junk results at the source and improves market comp quality. + if filters.must_exclude: + excludes = " ".join(f"-{t.strip()}" for t in filters.must_exclude if t.strip()) + base_params["_nkw"] = f"{base_params['_nkw']} {excludes}" + pages = max(1, filters.pages) page_params = [{**base_params, "_pgn": str(p)} for p in range(1, pages + 1)] diff --git a/web/src/stores/search.ts b/web/src/stores/search.ts index 3ab385a..40d903d 100644 --- a/web/src/stores/search.ts +++ b/web/src/stores/search.ts @@ -60,7 +60,9 @@ export interface SearchFilters { hideNewAccounts?: boolean hideSuspiciousPrice?: boolean hideDuplicatePhotos?: boolean - pages?: number // number of eBay result pages to fetch (48 listings/page, default 1) + pages?: number // number of eBay result pages to fetch (48 listings/page, default 1) + mustInclude?: string // comma-separated; client-side title filter + mustExclude?: string // comma-separated; forwarded to eBay -term AND client-side } // ── Store ──────────────────────────────────────────────────────────────────── @@ -86,6 +88,8 @@ export const useSearchStore = defineStore('search', () => { if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice)) if (filters.minPrice != null) params.set('min_price', String(filters.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.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim()) const res = await fetch(`/api/search?${params}`) if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`) diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue index 1910284..9ca2b34 100644 --- a/web/src/views/SearchView.vue +++ b/web/src/views/SearchView.vue @@ -56,6 +56,35 @@

{{ (filters.pages ?? 1) * 48 }} listings · {{ (filters.pages ?? 1) * 2 }} Playwright calls

+
+ Keywords +
+ + +
+
+ + +
+

Comma-separated · re-search to apply to eBay

+
+
Price
@@ -182,8 +211,18 @@ const filters = reactive({ hideSuspiciousPrice: false, hideDuplicatePhotos: false, pages: 1, + mustInclude: '', + mustExclude: '', }) +// Parse comma-separated keyword strings into trimmed, lowercase, non-empty term arrays +const parsedMustInclude = computed(() => + (filters.mustInclude ?? '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean) +) +const parsedMustExclude = computed(() => + (filters.mustExclude ?? '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean) +) + const CONDITIONS = [ { value: 'new', label: 'New' }, { value: 'like_new', label: 'Like New' }, @@ -236,6 +275,11 @@ function passesFilter(listing: Listing): boolean { const trust = store.trustScores.get(listing.platform_listing_id) const seller = store.sellers.get(listing.seller_platform_id) + // Keyword filtering — substring match on lowercased title + const title = listing.title.toLowerCase() + if (parsedMustInclude.value.some(term => !title.includes(term))) return false + if (parsedMustExclude.value.some(term => title.includes(term))) return false + if (filters.minTrustScore && trust && trust.composite_score < filters.minTrustScore) return false if (filters.minPrice != null && listing.price < filters.minPrice) return false if (filters.maxPrice != null && listing.price > filters.maxPrice) return false @@ -423,6 +467,20 @@ async function onSearch() { height: 14px; } +.filter-input--keyword { + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.filter-input--exclude { + border-color: color-mix(in srgb, var(--color-error) 40%, var(--color-border)); +} + +.filter-input--exclude:focus { + outline: none; + border-color: var(--color-error); +} + .filter-pages { display: flex; gap: var(--space-1);