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:
parent
ea78b9c2cd
commit
11f2a3c2b3
5 changed files with 87 additions and 3 deletions
16
api/main.py
16
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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue