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"}
|
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")
|
@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():
|
if not q.strip():
|
||||||
return {"listings": [], "trust_scores": {}, "sellers": {}, "market_price": None}
|
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,
|
max_price=max_price if max_price > 0 else None,
|
||||||
min_price=min_price if min_price > 0 else None,
|
min_price=min_price if min_price > 0 else None,
|
||||||
pages=max(1, pages),
|
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.
|
# Each adapter gets its own Store (SQLite connection) — required for thread safety.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ class SearchFilters:
|
||||||
condition: Optional[list[str]] = field(default_factory=list)
|
condition: Optional[list[str]] = field(default_factory=list)
|
||||||
location_radius_km: Optional[int] = None
|
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):
|
class PlatformAdapter(ABC):
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,12 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
if codes:
|
if codes:
|
||||||
base_params["LH_ItemCondition"] = "|".join(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)
|
pages = max(1, filters.pages)
|
||||||
page_params = [{**base_params, "_pgn": str(p)} for p in range(1, pages + 1)]
|
page_params = [{**base_params, "_pgn": str(p)} for p in range(1, pages + 1)]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export interface SearchFilters {
|
||||||
hideSuspiciousPrice?: boolean
|
hideSuspiciousPrice?: boolean
|
||||||
hideDuplicatePhotos?: 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 ────────────────────────────────────────────────────────────────────
|
// ── Store ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -86,6 +88,8 @@ export const useSearchStore = defineStore('search', () => {
|
||||||
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice))
|
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice))
|
||||||
if (filters.minPrice != null) params.set('min_price', String(filters.minPrice))
|
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.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}`)
|
const res = await fetch(`/api/search?${params}`)
|
||||||
if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`)
|
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>
|
<p class="filter-pages-hint">{{ (filters.pages ?? 1) * 48 }} listings · {{ (filters.pages ?? 1) * 2 }} Playwright calls</p>
|
||||||
</fieldset>
|
</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">
|
<fieldset class="filter-group">
|
||||||
<legend class="filter-label">Price</legend>
|
<legend class="filter-label">Price</legend>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
|
|
@ -182,8 +211,18 @@ const filters = reactive<SearchFilters>({
|
||||||
hideSuspiciousPrice: false,
|
hideSuspiciousPrice: false,
|
||||||
hideDuplicatePhotos: false,
|
hideDuplicatePhotos: false,
|
||||||
pages: 1,
|
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 = [
|
const CONDITIONS = [
|
||||||
{ value: 'new', label: 'New' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'like_new', label: 'Like 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 trust = store.trustScores.get(listing.platform_listing_id)
|
||||||
const seller = store.sellers.get(listing.seller_platform_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.minTrustScore && trust && trust.composite_score < filters.minTrustScore) return false
|
||||||
if (filters.minPrice != null && listing.price < filters.minPrice) return false
|
if (filters.minPrice != null && listing.price < filters.minPrice) return false
|
||||||
if (filters.maxPrice != null && listing.price > filters.maxPrice) return false
|
if (filters.maxPrice != null && listing.price > filters.maxPrice) return false
|
||||||
|
|
@ -423,6 +467,20 @@ async function onSearch() {
|
||||||
height: 14px;
|
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 {
|
.filter-pages {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue