feat(snipe): keyword must-include/must-exclude filtering

- Two sidebar fields: 'Must include' and 'Must exclude' (comma-separated)
- Must-exclude terms forwarded to eBay _nkw as -term prefixes (native eBay
  support) so exclusions reduce the eBay result set at the source — improves
  market comp quality as a side effect
- Must-include applied client-side only (substring, case-insensitive)
- Both applied client-side via passesFilter() for instant response without
  re-fetching (cache-friendly)
- Exclude input has subtle red border tint (color-mix) to signal intent
- Hint text: 're-search to apply to eBay' reminds user negatives need a
  new search to take effect at the eBay level
This commit is contained in:
pyr0ball 2026-03-25 22:54:24 -07:00
parent ea78b9c2cd
commit 11f2a3c2b3
5 changed files with 87 additions and 3 deletions

View file

@ -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.

View file

@ -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):

View file

@ -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)]

View file

@ -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}`)

View file

@ -56,6 +56,35 @@
<p class="filter-pages-hint">{{ (filters.pages ?? 1) * 48 }} listings · {{ (filters.pages ?? 1) * 2 }} Playwright calls</p>
</fieldset>
<fieldset class="filter-group">
<legend class="filter-label">Keywords</legend>
<div class="filter-row">
<label class="filter-label-sm" for="f-include">Must include</label>
<input
id="f-include"
v-model="filters.mustInclude"
type="text"
class="filter-input filter-input--keyword"
placeholder="16gb, founders…"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="filter-row">
<label class="filter-label-sm" for="f-exclude">Must exclude</label>
<input
id="f-exclude"
v-model="filters.mustExclude"
type="text"
class="filter-input filter-input--keyword filter-input--exclude"
placeholder="broken, parts…"
autocomplete="off"
spellcheck="false"
/>
</div>
<p class="filter-pages-hint">Comma-separated · re-search to apply to eBay</p>
</fieldset>
<fieldset class="filter-group">
<legend class="filter-label">Price</legend>
<div class="filter-row">
@ -182,8 +211,18 @@ const filters = reactive<SearchFilters>({
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);