Add GET /api/search/async that returns HTTP 202 immediately and streams
scrape results via SSE to avoid nginx 120s timeouts on slow eBay searches.
Backend:
- New GET /api/search/async endpoint submits scraping to ThreadPoolExecutor
and returns {session_id, status: "queued"} before scrape begins
- Background worker runs same pipeline as synchronous search, pushing
typed SSE events: "listings" (initial batch), "update" (enrichment),
"market_price", and None sentinel
- Existing GET /api/updates/{session_id} passes new event types through
as-is (already a generic pass-through); deadline extended to 150s
- Module-level _search_executor (max_workers=4) caps concurrent scrape sessions
Frontend (search.ts):
- search() now calls /api/search/async instead of /api/search
- loading stays true until first "listings" SSE event arrives
- _openUpdates() handles new typed events: "listings", "market_price",
"update"; legacy untyped enrichment events still handled
- cancelSearch() now also closes any open SSE stream
Tests: tests/test_async_search.py (6 tests) covering 202 response,
session_id registration in _update_queues, empty query path, UUID format,
and no-Chromium guarantee. All 159 pre-existing tests still pass.
Closes#49. Also closes Forgejo issue #1 (SSE enrichment streaming, already
implemented; async search completes the picture).
Replaces the Coming Soon placeholder. Clicking Details on any card opens
a full trust breakdown view:
- SVG score ring with composite score and colour-coded verdict label
- Auto-generated verdict text (identifies worst signals in plain English)
- Signal table with mini-bars: Feedback Volume/Ratio, Account Age,
Price vs Market, Category History — pending state shown for unresolved
- Red flag badges (hard vs soft) above the score ring
- Photo carousel with prev/next controls and img-error skip
- Seller panel (feedback count/ratio, account age, pending enrichment note)
- Block seller inline form wired to POST /api/blocklist
- Triple Red pulsing border easter egg carried over from card
- Not-found state for direct URL access (store cleared on refresh)
- Responsive: single-column layout on ≤640px
- ListingCard: adds Details RouterLink to price column
- search store: adds getListing(id) lookup helper
After a search, the API now returns a session_id. If any trust scores are
partial (pending seller age or category data), the frontend opens a
Server-Sent Events stream to /api/updates/{session_id}. As the background
BTF (account age) and category enrichment threads complete, they re-score
affected listings and push updated TrustScore payloads over SSE. The
frontend patches the trustScores and sellers maps reactively so signal
dots light up without requiring a manual re-search.
Backend:
- _update_queues registry maps session_id -> SimpleQueue (thread-safe bridge)
- _trigger_scraper_enrichment accepts session_id/user_db/query, builds a
seller->listings map, calls _push_updates() after each enrichment pass
which re-scores, saves trust scores, and puts events on the queue
- New GET /api/updates/{session_id} SSE endpoint: polls queue every 500ms,
emits heartbeats every 15s, closes on sentinel None or 90s timeout
- search endpoint generates session_id and returns it in response
Frontend:
- search store adds enriching state and _openUpdates() / closeUpdates()
- On search completion, if partial scores exist, opens EventSource stream
- onmessage: patches trustScores and sellers maps (new Map() to trigger
Vue reactivity), updates marketPrice if included
- on 'done' event or error: closes stream, enriching = false
- SearchView: pulsing 'Updating scores...' badge in toolbar while enriching
- _affiliate_url() helper appends EPN params when EBAY_AFFILIATE_CAMPAIGN_ID set
- Clean /itm/ URLs by default (no affiliate tracking without explicit opt-in)
- affiliate_active flag in search response drives frontend disclosure
- SearchView shows 'Links may include an affiliate code' when active
- .env.example documents EBAY_AFFILIATE_CAMPAIGN_ID with EPN registration link
- Closes#19
- compose.cloud.yml: snipe-cloud project, proper Docker bridge network
(api is internal-only, no host port), port 8514 for nginx
- docker/web/Dockerfile: VITE_BASE_URL + VITE_API_BASE build args so
Vite bakes the /snipe path prefix into the bundle at cloud build time
- docker/web/nginx.cloud.conf: upstream api:8510 via Docker network
(vs 172.17.0.1:8510 in dev which uses host networking)
- manage.sh: cloud-start/stop/restart/status/logs/build commands
- stores/search.ts: VITE_API_BASE prefix on all /api fetch calls
Gate: Caddy basicauth (username: cf) — temporary gate while proper
Heimdall license validation UI is built. Password stored at
/devl/snipe-cloud-data/.beta-password (host-only, not in repo).
Note: Caddyfile updated separately (caddy-proxy volume, not this repo).
- 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
- Parallel execution: search() and get_completed_sales() now run
concurrently via ThreadPoolExecutor — each gets its own Store/SQLite
connection for thread safety. First cold search time ~halved.
- Pagination: SearchFilters.pages (default 1) controls how many eBay
result pages are fetched. Both search and sold-comps support up to 3
parallel Playwright sessions per call (capped to avoid Xvfb overload).
UI: segmented 1/2/3/5 pages selector in filter sidebar with cost hint.
- True median: get_completed_sales() now averages the two middle values
for even-length price lists instead of always taking the lower bound.
- Fix suspicious_price false positive: aggregator now checks
signal_scores.get("price_vs_market") == 0 (pre-None-substitution)
so listings without market data are never flagged as suspicious.
- Fix title pollution: scraper strips eBay's hidden screen-reader span
("Opens in a new window or tab") from listing titles via regex.
Lazy-imports playwright/playwright_stealth inside _get() so pure
parsing functions are importable without the full browser stack.
- Tests: 48 pass on host (scraper tests now runnable without Docker),
new regression guards for all three bug fixes.