diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..966a414 --- /dev/null +++ b/app/app.py @@ -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() diff --git a/app/ui/Search.py b/app/ui/Search.py new file mode 100644 index 0000000..3af9558 --- /dev/null +++ b/app/ui/Search.py @@ -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) diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/components/__init__.py b/app/ui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/components/filters.py b/app/ui/components/filters.py new file mode 100644 index 0000000..94e54f5 --- /dev/null +++ b/app/ui/components/filters.py @@ -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 diff --git a/app/ui/components/listing_row.py b/app/ui/components/listing_row.py new file mode 100644 index 0000000..34fbd54 --- /dev/null +++ b/app/ui/components/listing_row.py @@ -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'{_flag_label(f)}' + 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() diff --git a/app/wizard/__init__.py b/app/wizard/__init__.py new file mode 100644 index 0000000..70e86af --- /dev/null +++ b/app/wizard/__init__.py @@ -0,0 +1,3 @@ +from .setup import SnipeSetupWizard + +__all__ = ["SnipeSetupWizard"] diff --git a/app/wizard/setup.py b/app/wizard/setup.py new file mode 100644 index 0000000..2ac8187 --- /dev/null +++ b/app/wizard/setup.py @@ -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 diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ui/test_filters.py b/tests/ui/test_filters.py new file mode 100644 index 0000000..fd716bf --- /dev/null +++ b/tests/ui/test_filters.py @@ -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