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
85 lines
3 KiB
Python
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
|