snipe/app/platforms/ebay/query_builder.py
pyr0ball 98695b00f0 feat(snipe): eBay trust scoring MVP — search, filters, enrichment, comps
Core trust scoring:
- Five metadata signals (account age, feedback count/ratio, price vs market,
  category history), composited 0–100
- CV-based price signal suppression for heterogeneous search results
  (e.g. mixed laptop generations won't false-positive suspicious_price)
- Expanded scratch/dent title detection: evasive redirects, functional problem
  phrases, DIY/repair indicators
- Hard filters: new_account, established_bad_actor
- Soft flags: low_feedback, suspicious_price, duplicate_photo, scratch_dent,
  long_on_market, significant_price_drop

Search & filtering:
- Browse API adapter (up to 200 items/page) + Playwright scraper fallback
- OR-group query expansion for comprehensive variant coverage
- Must-include (AND/ANY/groups), must-exclude, category, price range filters
- Saved searches with full filter round-trip via URL params

Seller enrichment:
- Background BTF /itm/ scraping for account age (Kasada-safe headed Chromium)
- On-demand enrichment: POST /api/enrich + ListingCard ↻ button
- Category history derived from Browse API categories field (free, no extra calls)
- Shopping API GetUserProfile inline enrichment for API adapter

Market comps:
- eBay Marketplace Insights API with Browse API fallback (catches 403 + 404)
- Comps prioritised in ThreadPoolExecutor (submitted first)

Infrastructure:
- Staging DB fields: times_seen, first_seen_at, price_at_first_seen, category_name
- Migrations 004 (staging tracking) + 005 (listing category)
- eBay webhook handler stub
- Cloud compose stack (compose.cloud.yml)
- Vue frontend: search store, saved searches store, ListingCard, filter sidebar

Docs:
- README fully rewritten to reflect MVP status + full feature documentation
- Roadmap table linked to all 13 Forgejo issues
2026-03-26 23:37:09 -07:00

85 lines
3 KiB
Python

"""
Build eBay-compatible boolean search queries from OR groups.
eBay honors parenthetical OR groups in the _nkw search parameter:
(term1,term2,term3) → must contain at least one of these terms
-term / -"phrase" → must NOT contain this term / phrase
space between groups → implicit AND
expand_queries() generates one eBay query per term in the smallest OR group,
using eBay's OR syntax for all remaining groups. This guarantees coverage even
if eBay's relevance ranking would suppress some matches in a single combined query.
Example:
base = "GPU"
or_groups = [["16gb","24gb","40gb","48gb"], ["nvidia","quadro","rtx","geforce","titan"]]
→ 4 queries (one per memory size, brand group as eBay OR):
"GPU 16gb (nvidia,quadro,rtx,geforce,titan)"
"GPU 24gb (nvidia,quadro,rtx,geforce,titan)"
"GPU 40gb (nvidia,quadro,rtx,geforce,titan)"
"GPU 48gb (nvidia,quadro,rtx,geforce,titan)"
"""
from __future__ import annotations
def _group_to_ebay(group: list[str]) -> str:
"""Convert a list of alternatives to an eBay OR clause."""
clean = [t.strip() for t in group if t.strip()]
if not clean:
return ""
if len(clean) == 1:
return clean[0]
return f"({','.join(clean)})"
def build_ebay_query(base_query: str, or_groups: list[list[str]]) -> str:
"""
Build a single eBay _nkw query string using eBay's parenthetical OR syntax.
Exclusions are handled separately via SearchFilters.must_exclude.
"""
parts = [base_query.strip()]
for group in or_groups:
clause = _group_to_ebay(group)
if clause:
parts.append(clause)
return " ".join(p for p in parts if p)
def expand_queries(base_query: str, or_groups: list[list[str]]) -> list[str]:
"""
Expand OR groups into one eBay query per term in the smallest group,
using eBay's OR syntax for all remaining groups.
This guarantees every term in the pivot group is explicitly searched,
which prevents eBay's relevance engine from silently skipping rare variants.
Falls back to a single query when there are no OR groups.
"""
if not or_groups:
return [base_query.strip()]
# Pivot on the smallest group to minimise the number of Playwright calls
smallest_idx = min(range(len(or_groups)), key=lambda i: len(or_groups[i]))
pivot = or_groups[smallest_idx]
rest = [g for i, g in enumerate(or_groups) if i != smallest_idx]
queries = []
for term in pivot:
q = build_ebay_query(base_query, [[term]] + rest)
queries.append(q)
return queries
def parse_groups(raw: str) -> list[list[str]]:
"""
Parse a Groups-mode must_include string into nested OR groups.
Format: comma separates groups (AND), pipe separates alternatives within a group (OR).
"16gb|24gb|48gb, nvidia|rtx|geforce"
→ [["16gb","24gb","48gb"], ["nvidia","rtx","geforce"]]
"""
groups = []
for chunk in raw.split(","):
alts = [t.strip().lower() for t in chunk.split("|") if t.strip()]
if alts:
groups.append(alts)
return groups