feat: add search UI with dynamic filter sidebar and listing rows
This commit is contained in:
parent
95ccd8f1b3
commit
59791fd163
10 changed files with 442 additions and 0 deletions
19
app/app.py
Normal file
19
app/app.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Streamlit entrypoint."""
|
||||
from pathlib import Path
|
||||
import streamlit as st
|
||||
from app.wizard import SnipeSetupWizard
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Snipe",
|
||||
page_icon="🎯",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded",
|
||||
)
|
||||
|
||||
wizard = SnipeSetupWizard(env_path=Path(".env"))
|
||||
if not wizard.is_configured():
|
||||
wizard.run()
|
||||
st.stop()
|
||||
|
||||
from app.ui.Search import render
|
||||
render()
|
||||
127
app/ui/Search.py
Normal file
127
app/ui/Search.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Main search + results page."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
import streamlit as st
|
||||
from circuitforge_core.config import load_env
|
||||
from app.db.store import Store
|
||||
from app.platforms import SearchFilters
|
||||
from app.platforms.ebay.auth import EbayTokenManager
|
||||
from app.platforms.ebay.adapter import EbayAdapter
|
||||
from app.trust import TrustScorer
|
||||
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
|
||||
from app.ui.components.listing_row import render_listing_row
|
||||
|
||||
load_env(Path(".env"))
|
||||
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
||||
_DB_PATH.parent.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def _get_adapter() -> EbayAdapter:
|
||||
store = Store(_DB_PATH)
|
||||
tokens = EbayTokenManager(
|
||||
client_id=os.environ.get("EBAY_CLIENT_ID", ""),
|
||||
client_secret=os.environ.get("EBAY_CLIENT_SECRET", ""),
|
||||
env=os.environ.get("EBAY_ENV", "production"),
|
||||
)
|
||||
return EbayAdapter(tokens, store, env=os.environ.get("EBAY_ENV", "production"))
|
||||
|
||||
|
||||
def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
|
||||
import json
|
||||
if trust and trust.composite_score < state.min_trust_score:
|
||||
return False
|
||||
if state.min_price and listing.price < state.min_price:
|
||||
return False
|
||||
if state.max_price and listing.price > state.max_price:
|
||||
return False
|
||||
if state.conditions and listing.condition not in state.conditions:
|
||||
return False
|
||||
if seller:
|
||||
if seller.account_age_days < state.min_account_age_days:
|
||||
return False
|
||||
if seller.feedback_count < state.min_feedback_count:
|
||||
return False
|
||||
if seller.feedback_ratio < state.min_feedback_ratio:
|
||||
return False
|
||||
if trust:
|
||||
flags = json.loads(trust.red_flags_json or "[]")
|
||||
if state.hide_new_accounts and "account_under_30_days" in flags:
|
||||
return False
|
||||
if state.hide_suspicious_price and "suspicious_price" in flags:
|
||||
return False
|
||||
if state.hide_duplicate_photos and "duplicate_photo" in flags:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def render() -> None:
|
||||
st.title("🔍 Snipe — eBay Listing Search")
|
||||
|
||||
col_q, col_price, col_btn = st.columns([4, 2, 1])
|
||||
query = col_q.text_input("Search", placeholder="RTX 4090 GPU", label_visibility="collapsed")
|
||||
max_price = col_price.number_input("Max price $", min_value=0.0, value=0.0,
|
||||
step=50.0, label_visibility="collapsed")
|
||||
search_clicked = col_btn.button("Search", use_container_width=True)
|
||||
|
||||
if not search_clicked or not query:
|
||||
st.info("Enter a search term and click Search.")
|
||||
return
|
||||
|
||||
with st.spinner("Fetching listings..."):
|
||||
try:
|
||||
adapter = _get_adapter()
|
||||
filters = SearchFilters(max_price=max_price if max_price > 0 else None)
|
||||
listings = adapter.search(query, filters)
|
||||
adapter.get_completed_sales(query) # warm the comps cache
|
||||
except Exception as e:
|
||||
st.error(f"eBay search failed: {e}")
|
||||
return
|
||||
|
||||
if not listings:
|
||||
st.warning("No listings found.")
|
||||
return
|
||||
|
||||
store = Store(_DB_PATH)
|
||||
for listing in listings:
|
||||
store.save_listing(listing)
|
||||
if listing.seller_platform_id:
|
||||
seller = adapter.get_seller(listing.seller_platform_id)
|
||||
if seller:
|
||||
store.save_seller(seller)
|
||||
|
||||
scorer = TrustScorer(store)
|
||||
trust_scores = scorer.score_batch(listings, query)
|
||||
pairs = list(zip(listings, trust_scores))
|
||||
|
||||
opts = build_filter_options(pairs)
|
||||
filter_state = render_filter_sidebar(pairs, opts)
|
||||
|
||||
sort_col = st.selectbox("Sort by", ["Trust score", "Price ↑", "Price ↓", "Newest"],
|
||||
label_visibility="collapsed")
|
||||
|
||||
def sort_key(pair):
|
||||
l, t = pair
|
||||
if sort_col == "Trust score": return -(t.composite_score if t else 0)
|
||||
if sort_col == "Price ↑": return l.price
|
||||
if sort_col == "Price ↓": return -l.price
|
||||
return l.listing_age_days
|
||||
|
||||
sorted_pairs = sorted(pairs, key=sort_key)
|
||||
visible = [(l, t) for l, t in sorted_pairs
|
||||
if _passes_filter(l, t, store.get_seller("ebay", l.seller_platform_id), filter_state)]
|
||||
hidden_count = len(sorted_pairs) - len(visible)
|
||||
|
||||
st.caption(f"{len(visible)} results · {hidden_count} hidden by filters")
|
||||
|
||||
for listing, trust in visible:
|
||||
seller = store.get_seller("ebay", listing.seller_platform_id)
|
||||
render_listing_row(listing, trust, seller)
|
||||
|
||||
if hidden_count:
|
||||
if st.button(f"Show {hidden_count} hidden results"):
|
||||
visible_ids = {(l.platform, l.platform_listing_id) for l, _ in visible}
|
||||
for listing, trust in sorted_pairs:
|
||||
if (listing.platform, listing.platform_listing_id) not in visible_ids:
|
||||
seller = store.get_seller("ebay", listing.seller_platform_id)
|
||||
render_listing_row(listing, trust, seller)
|
||||
0
app/ui/__init__.py
Normal file
0
app/ui/__init__.py
Normal file
0
app/ui/components/__init__.py
Normal file
0
app/ui/components/__init__.py
Normal file
118
app/ui/components/filters.py
Normal file
118
app/ui/components/filters.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
import streamlit as st
|
||||
from app.db.models import Listing, TrustScore
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterOptions:
|
||||
price_min: float
|
||||
price_max: float
|
||||
conditions: dict[str, int] # condition → count
|
||||
score_bands: dict[str, int] # safe/review/skip → count
|
||||
has_real_photo: int = 0
|
||||
has_em_bag: int = 0
|
||||
duplicate_count: int = 0
|
||||
new_account_count: int = 0
|
||||
free_shipping_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterState:
|
||||
min_trust_score: int = 0
|
||||
min_price: Optional[float] = None
|
||||
max_price: Optional[float] = None
|
||||
min_account_age_days: int = 0
|
||||
min_feedback_count: int = 0
|
||||
min_feedback_ratio: float = 0.0
|
||||
conditions: list[str] = field(default_factory=list)
|
||||
hide_new_accounts: bool = False
|
||||
hide_marketing_photos: bool = False
|
||||
hide_suspicious_price: bool = False
|
||||
hide_duplicate_photos: bool = False
|
||||
|
||||
|
||||
def build_filter_options(
|
||||
pairs: list[tuple[Listing, TrustScore]],
|
||||
) -> FilterOptions:
|
||||
prices = [l.price for l, _ in pairs if l.price > 0]
|
||||
conditions: dict[str, int] = {}
|
||||
safe = review = skip = 0
|
||||
dup_count = new_acct = 0
|
||||
|
||||
for listing, ts in pairs:
|
||||
cond = listing.condition or "unknown"
|
||||
conditions[cond] = conditions.get(cond, 0) + 1
|
||||
if ts.composite_score >= 80:
|
||||
safe += 1
|
||||
elif ts.composite_score >= 50:
|
||||
review += 1
|
||||
else:
|
||||
skip += 1
|
||||
if ts.photo_hash_duplicate:
|
||||
dup_count += 1
|
||||
flags = json.loads(ts.red_flags_json or "[]")
|
||||
if "new_account" in flags or "account_under_30_days" in flags:
|
||||
new_acct += 1
|
||||
|
||||
return FilterOptions(
|
||||
price_min=min(prices) if prices else 0,
|
||||
price_max=max(prices) if prices else 0,
|
||||
conditions=conditions,
|
||||
score_bands={"safe": safe, "review": review, "skip": skip},
|
||||
duplicate_count=dup_count,
|
||||
new_account_count=new_acct,
|
||||
)
|
||||
|
||||
|
||||
def render_filter_sidebar(
|
||||
pairs: list[tuple[Listing, TrustScore]],
|
||||
opts: FilterOptions,
|
||||
) -> FilterState:
|
||||
"""Render filter sidebar and return current FilterState."""
|
||||
state = FilterState()
|
||||
|
||||
st.sidebar.markdown("### Filters")
|
||||
st.sidebar.caption(f"{len(pairs)} results")
|
||||
|
||||
state.min_trust_score = st.sidebar.slider("Min trust score", 0, 100, 0, key="min_trust")
|
||||
st.sidebar.caption(
|
||||
f"🟢 Safe (80+): {opts.score_bands['safe']} "
|
||||
f"🟡 Review (50–79): {opts.score_bands['review']} "
|
||||
f"🔴 Skip (<50): {opts.score_bands['skip']}"
|
||||
)
|
||||
|
||||
st.sidebar.markdown("**Price**")
|
||||
col1, col2 = st.sidebar.columns(2)
|
||||
state.min_price = col1.number_input("Min $", value=opts.price_min, step=50.0, key="min_p")
|
||||
state.max_price = col2.number_input("Max $", value=opts.price_max, step=50.0, key="max_p")
|
||||
|
||||
state.min_account_age_days = st.sidebar.slider(
|
||||
"Account age (min days)", 0, 365, 0, key="age")
|
||||
state.min_feedback_count = st.sidebar.slider(
|
||||
"Feedback count (min)", 0, 500, 0, key="fb_count")
|
||||
state.min_feedback_ratio = st.sidebar.slider(
|
||||
"Positive feedback % (min)", 0, 100, 0, key="fb_ratio") / 100.0
|
||||
|
||||
if opts.conditions:
|
||||
st.sidebar.markdown("**Condition**")
|
||||
selected = []
|
||||
for cond, count in sorted(opts.conditions.items()):
|
||||
if st.sidebar.checkbox(f"{cond} ({count})", value=True, key=f"cond_{cond}"):
|
||||
selected.append(cond)
|
||||
state.conditions = selected
|
||||
|
||||
st.sidebar.markdown("**Hide if flagged**")
|
||||
state.hide_new_accounts = st.sidebar.checkbox(
|
||||
f"New account (<30d) ({opts.new_account_count})", key="hide_new")
|
||||
state.hide_suspicious_price = st.sidebar.checkbox("Suspicious price", key="hide_price")
|
||||
state.hide_duplicate_photos = st.sidebar.checkbox(
|
||||
f"Duplicate photo ({opts.duplicate_count})", key="hide_dup")
|
||||
|
||||
if st.sidebar.button("Reset filters", key="reset"):
|
||||
st.rerun()
|
||||
|
||||
return state
|
||||
85
app/ui/components/listing_row.py
Normal file
85
app/ui/components/listing_row.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Render a single listing row with trust score, badges, and error states."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import streamlit as st
|
||||
from app.db.models import Listing, TrustScore, Seller
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _score_colour(score: int) -> str:
|
||||
if score >= 80: return "🟢"
|
||||
if score >= 50: return "🟡"
|
||||
return "🔴"
|
||||
|
||||
|
||||
def _flag_label(flag: str) -> str:
|
||||
labels = {
|
||||
"new_account": "✗ New account",
|
||||
"account_under_30_days": "⚠ Account <30d",
|
||||
"low_feedback_count": "⚠ Low feedback",
|
||||
"suspicious_price": "✗ Suspicious price",
|
||||
"duplicate_photo": "✗ Duplicate photo",
|
||||
"established_bad_actor": "✗ Bad actor",
|
||||
"marketing_photo": "✗ Marketing photo",
|
||||
}
|
||||
return labels.get(flag, f"⚠ {flag}")
|
||||
|
||||
|
||||
def render_listing_row(
|
||||
listing: Listing,
|
||||
trust: Optional[TrustScore],
|
||||
seller: Optional[Seller] = None,
|
||||
) -> None:
|
||||
col_img, col_info, col_score = st.columns([1, 5, 2])
|
||||
|
||||
with col_img:
|
||||
if listing.photo_urls:
|
||||
# Spec requires graceful 404 handling: show placeholder on failure
|
||||
try:
|
||||
import requests as _req
|
||||
r = _req.head(listing.photo_urls[0], timeout=3, allow_redirects=True)
|
||||
if r.status_code == 200:
|
||||
st.image(listing.photo_urls[0], width=80)
|
||||
else:
|
||||
st.markdown("📷 *Photo unavailable*")
|
||||
except Exception:
|
||||
st.markdown("📷 *Photo unavailable*")
|
||||
else:
|
||||
st.markdown("📷 *No photo*")
|
||||
|
||||
with col_info:
|
||||
st.markdown(f"**{listing.title}**")
|
||||
if seller:
|
||||
age_str = f"{seller.account_age_days // 365}yr" if seller.account_age_days >= 365 \
|
||||
else f"{seller.account_age_days}d"
|
||||
st.caption(
|
||||
f"{seller.username} · {seller.feedback_count} fb · "
|
||||
f"{seller.feedback_ratio*100:.1f}% · member {age_str}"
|
||||
)
|
||||
else:
|
||||
st.caption(f"{listing.seller_platform_id} · *Seller data unavailable*")
|
||||
|
||||
if trust:
|
||||
flags = json.loads(trust.red_flags_json or "[]")
|
||||
if flags:
|
||||
badge_html = " ".join(
|
||||
f'<span style="background:#c33;color:#fff;padding:1px 5px;'
|
||||
f'border-radius:3px;font-size:11px">{_flag_label(f)}</span>'
|
||||
for f in flags
|
||||
)
|
||||
st.markdown(badge_html, unsafe_allow_html=True)
|
||||
if trust.score_is_partial:
|
||||
st.caption("⚠ Partial score — some data unavailable")
|
||||
else:
|
||||
st.caption("⚠ Could not score this listing")
|
||||
|
||||
with col_score:
|
||||
if trust:
|
||||
icon = _score_colour(trust.composite_score)
|
||||
st.metric(label="Trust", value=f"{icon} {trust.composite_score}")
|
||||
else:
|
||||
st.metric(label="Trust", value="?")
|
||||
st.markdown(f"**${listing.price:,.0f}**")
|
||||
st.markdown(f"[Open eBay ↗]({listing.url})")
|
||||
|
||||
st.divider()
|
||||
3
app/wizard/__init__.py
Normal file
3
app/wizard/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .setup import SnipeSetupWizard
|
||||
|
||||
__all__ = ["SnipeSetupWizard"]
|
||||
52
app/wizard/setup.py
Normal file
52
app/wizard/setup.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""First-run wizard: collect eBay credentials and write .env."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import streamlit as st
|
||||
from circuitforge_core.wizard import BaseWizard
|
||||
|
||||
|
||||
class SnipeSetupWizard(BaseWizard):
|
||||
"""
|
||||
Guides the user through first-run setup:
|
||||
1. Enter eBay Client ID (EBAY_CLIENT_ID) + Secret (EBAY_CLIENT_SECRET)
|
||||
2. Choose sandbox vs production
|
||||
3. Verify connection (token fetch)
|
||||
4. Write .env file
|
||||
"""
|
||||
|
||||
def __init__(self, env_path: Path = Path(".env")):
|
||||
self._env_path = env_path
|
||||
|
||||
def run(self) -> bool:
|
||||
"""Run the setup wizard. Returns True if setup completed successfully."""
|
||||
st.title("🎯 Snipe — First Run Setup")
|
||||
st.info(
|
||||
"To use Snipe, you need eBay developer credentials. "
|
||||
"Register at developer.ebay.com and create an app to get your Client ID (EBAY_CLIENT_ID) and Secret (EBAY_CLIENT_SECRET)."
|
||||
)
|
||||
|
||||
client_id = st.text_input("eBay Client ID (EBAY_CLIENT_ID)", type="password")
|
||||
client_secret = st.text_input("eBay Client Secret (EBAY_CLIENT_SECRET)", type="password")
|
||||
env = st.selectbox("eBay Environment", ["production", "sandbox"])
|
||||
|
||||
if st.button("Save and verify"):
|
||||
if not client_id or not client_secret:
|
||||
st.error("Both Client ID and Secret are required.")
|
||||
return False
|
||||
# Write .env
|
||||
self._env_path.write_text(
|
||||
f"EBAY_CLIENT_ID={client_id}\n"
|
||||
f"EBAY_CLIENT_SECRET={client_secret}\n"
|
||||
f"EBAY_ENV={env}\n"
|
||||
f"SNIPE_DB=data/snipe.db\n"
|
||||
)
|
||||
st.success(f".env written to {self._env_path}. Reload the app to begin searching.")
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True if .env exists and has eBay credentials."""
|
||||
if not self._env_path.exists():
|
||||
return False
|
||||
text = self._env_path.read_text()
|
||||
return "EBAY_CLIENT_ID=" in text and "EBAY_CLIENT_SECRET=" in text
|
||||
0
tests/ui/__init__.py
Normal file
0
tests/ui/__init__.py
Normal file
38
tests/ui/test_filters.py
Normal file
38
tests/ui/test_filters.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from app.db.models import Listing, TrustScore
|
||||
from app.ui.components.filters import build_filter_options
|
||||
|
||||
|
||||
def _listing(price, condition, score):
|
||||
return (
|
||||
Listing("ebay", "1", "GPU", price, "USD", condition, "u", "https://ebay.com", [], 1),
|
||||
TrustScore(0, score, 10, 10, 10, 10, 10),
|
||||
)
|
||||
|
||||
|
||||
def test_price_range_from_results():
|
||||
pairs = [_listing(500, "used", 80), _listing(1200, "new", 60)]
|
||||
opts = build_filter_options(pairs)
|
||||
assert opts.price_min == 500
|
||||
assert opts.price_max == 1200
|
||||
|
||||
|
||||
def test_conditions_from_results():
|
||||
pairs = [_listing(500, "used", 80), _listing(1200, "new", 60), _listing(800, "used", 70)]
|
||||
opts = build_filter_options(pairs)
|
||||
assert "used" in opts.conditions
|
||||
assert opts.conditions["used"] == 2
|
||||
assert opts.conditions["new"] == 1
|
||||
|
||||
|
||||
def test_missing_condition_not_included():
|
||||
pairs = [_listing(500, "used", 80)]
|
||||
opts = build_filter_options(pairs)
|
||||
assert "new" not in opts.conditions
|
||||
|
||||
|
||||
def test_trust_score_bands():
|
||||
pairs = [_listing(500, "used", 85), _listing(700, "new", 60), _listing(400, "used", 20)]
|
||||
opts = build_filter_options(pairs)
|
||||
assert opts.score_bands["safe"] == 1 # 80+
|
||||
assert opts.score_bands["review"] == 1 # 50–79
|
||||
assert opts.score_bands["skip"] == 1 # <50
|
||||
Loading…
Reference in a new issue