Compare commits
No commits in common. "main" and "v0.5.0" have entirely different histories.
76 changed files with 745 additions and 8169 deletions
37
.env.example
37
.env.example
|
|
@ -19,25 +19,6 @@ EBAY_SANDBOX_CERT_ID=
|
|||
# production | sandbox
|
||||
EBAY_ENV=production
|
||||
|
||||
# ── eBay OAuth — Authorization Code (user account connection) ─────────────────
|
||||
# Enables paid-tier users to connect their personal eBay account for instant
|
||||
# trust scoring via Trading API GetUser (account age + per-category feedback).
|
||||
# Without this, Snipe falls back to Shopping API + Playwright scraping.
|
||||
#
|
||||
# Setup steps:
|
||||
# 1. Go to https://developer.ebay.com/my/keys → select your Production app
|
||||
# 2. Under "Auth Accepted URL / RuName", create a new entry:
|
||||
# - Callback URL: https://your-domain/api/ebay/callback
|
||||
# (e.g. https://menagerie.circuitforge.tech/snipe/api/ebay/callback)
|
||||
# - Snipe generates the redirect automatically — just register the URL above
|
||||
# 3. Copy the RuName value (looks like "YourName-AppName-PRD-xxx-yyy")
|
||||
# and paste it as EBAY_RUNAME below.
|
||||
# 4. Set EBAY_OAUTH_REDIRECT_URI to the same HTTPS callback URL.
|
||||
#
|
||||
# Self-hosted: your callback URL must be HTTPS and publicly reachable.
|
||||
# EBAY_RUNAME=YourName-AppName-PRD-xxxxxxxx-xxxxxxxx
|
||||
# EBAY_OAUTH_REDIRECT_URI=https://your-domain/api/ebay/callback
|
||||
|
||||
# ── eBay Account Deletion Webhook ──────────────────────────────────────────────
|
||||
# Register endpoint at https://developer.ebay.com/my/notification — required for
|
||||
# production key activation. Set EBAY_NOTIFICATION_ENDPOINT to the public HTTPS
|
||||
|
|
@ -51,9 +32,6 @@ EBAY_WEBHOOK_VERIFY_SIGNATURES=true
|
|||
# ── Database ───────────────────────────────────────────────────────────────────
|
||||
SNIPE_DB=data/snipe.db
|
||||
|
||||
# Product identifier reported in cf-orch coordinator analytics for per-app breakdown
|
||||
CF_APP_NAME=snipe
|
||||
|
||||
# ── Cloud mode (managed / menagerie instance only) ─────────────────────────────
|
||||
# Leave unset for self-hosted / local use. When set, per-user DB isolation
|
||||
# and Heimdall licensing are enabled. compose.cloud.yml sets CLOUD_MODE=true
|
||||
|
|
@ -98,25 +76,16 @@ CF_APP_NAME=snipe
|
|||
# OLLAMA_HOST=http://localhost:11434
|
||||
# OLLAMA_MODEL=llava:7b
|
||||
|
||||
# GPU Server — routes vision/LLM tasks to a cf-orch coordinator for VRAM management.
|
||||
# CF Orchestrator — routes vision/LLM tasks to a cf-orch coordinator for VRAM management.
|
||||
# Self-hosted: point at a local cf-orch coordinator if you have one running.
|
||||
# Cloud (internal): managed coordinator at orch.circuitforge.tech.
|
||||
# Leave unset to run vision tasks inline (no VRAM coordination).
|
||||
# GPU_SERVER_URL=http://10.1.10.71:7700
|
||||
#
|
||||
# CF_ORCH_URL is accepted as a backward-compat alias for GPU_SERVER_URL.
|
||||
# CF_ORCH_URL=http://10.1.10.71:7700
|
||||
#
|
||||
# cf-orch agent (compose --profile orch) — coordinator URL for the sidecar agent.
|
||||
# Defaults to GPU_SERVER_URL if unset.
|
||||
# Defaults to CF_ORCH_URL if unset.
|
||||
# CF_ORCH_COORDINATOR_URL=http://10.1.10.71:7700
|
||||
|
||||
# ── Shared Postgres (optional — strongly recommended for cloud/multi-user) ────
|
||||
# When set, sellers, market_comps, reported_sellers, and scammer_blocklist are
|
||||
# stored in Postgres instead of SQLite. Required to avoid database-locked errors
|
||||
# under concurrent load (>10 simultaneous search users).
|
||||
# Cloud instances: set to the cf-postgres DSN. Self-hosted: leave unset for SQLite.
|
||||
# SNIPE_SHARED_DB_URL=postgresql://snipe:<password>@localhost:5432/snipe_shared
|
||||
|
||||
# ── Community DB (optional) ──────────────────────────────────────────────────
|
||||
# When set, seller trust signals (confirmed scammers added to blocklist) are
|
||||
# published to the shared community PostgreSQL for cross-user signal aggregation.
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,4 +10,3 @@ data/
|
|||
web/node_modules/
|
||||
web/dist/
|
||||
config/llm.yaml
|
||||
.worktrees/
|
||||
|
|
|
|||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -6,30 +6,6 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
|
||||
---
|
||||
|
||||
## [0.5.1] — 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
**Reported sellers tracking** — after bulk-reporting sellers to eBay Trust & Safety, cards show a muted "Reported to eBay" badge so users know not to re-report the same seller.
|
||||
|
||||
- Migration 012: `reported_sellers` table in user DB (UNIQUE on platform + seller ID, preserves first-report timestamp on re-report).
|
||||
- `Store.mark_reported` / `list_reported` methods.
|
||||
- `POST /api/reported` + `GET /api/reported` endpoints.
|
||||
- `reported` Pinia store: optimistic local update, best-effort server persistence.
|
||||
- `ListingCard`: accepts `sellerReported` prop; shows `.card__reported-badge` when true.
|
||||
- `App.vue`: loads reported store at startup alongside blocklist.
|
||||
|
||||
**Community blocklist share toggle** — Settings > Community section (signed-in users only, default OFF).
|
||||
|
||||
- Toggle persisted as `community.blocklist_share` via existing user preferences path system.
|
||||
- Backend `add_to_blocklist` now gates community signal publishing on opt-in preference; privacy-by-architecture: sharing is never implicit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- SSE live score push (snipe#1) verified working end-to-end: enrichment thread correctly streams re-scored trust scores via `SimpleQueue → StreamingResponse` generator, terminates with `event: done`. Closed.
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] — 2026-04-16
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ WORKDIR /app
|
|||
# System deps for Playwright/Chromium
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
xvfb \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install circuitforge-core from sibling directory (compose sets context: ..)
|
||||
|
|
|
|||
314
README.md
314
README.md
|
|
@ -1,87 +1,29 @@
|
|||
<!-- Logo coming soon — replace docs/snipe-logo.svg when final icon ships -->
|
||||
<div align="center">
|
||||
<img src="docs/snipe-logo.svg" alt="Snipe logo" width="120" />
|
||||
# Snipe — Auction Sniping & Listing Intelligence
|
||||
|
||||
# Snipe
|
||||
> *Part of the Circuit Forge LLC "AI for the tasks you hate most" suite.*
|
||||
|
||||
**Auction intelligence and sniping for people who don't trust the platform.**
|
||||
**Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next.
|
||||
|
||||
[](LICENSE)
|
||||
[]()
|
||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/releases)
|
||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe)
|
||||
[](https://docs.circuitforge.tech/snipe)
|
||||
**[Documentation](https://docs.circuitforge.tech/snipe/)** · [circuitforge.tech](https://circuitforge.tech)
|
||||
|
||||
*Part of the Circuit Forge LLC suite — "AI for the tasks the system made hard on purpose."*
|
||||
</div>
|
||||
## Quick install (self-hosted)
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/hero.png" alt="Snipe search page with filter panel and feature overview"/></td>
|
||||
<td><img src="docs/screenshots/results.png" alt="Search results — trust score badges, STEAL price flags, seller feedback, and market price comparison"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Why Snipe?
|
||||
|
||||
Auction platforms are designed to make you act fast and trust blindly. The closing countdown, the hidden price history, the new-account seller with one feedback — all of it is structured against the buyer.
|
||||
|
||||
Snipe inverts that. Before you place a bid, you get a trust score built from five independently sourced signals: seller account age, feedback volume, feedback ratio, price versus recent completed sales, and category history. A hard-coded red flag for new accounts or bad actors overrides the composite. Soft flags surface buried damage disclosures, duplicate photos, and listings that have been sitting unsold for weeks. When the listing is priced well below market, you see a STEAL badge — sourced from eBay Marketplace Insights, not from the seller's description.
|
||||
|
||||
The sniping engine — precise last-second bid submission with NTP (network time protocol) synchronization and soft-close handling — is next on the roadmap. The intelligence layer is live now.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Listing intelligence (live)
|
||||
|
||||
- **Trust scoring** — five-signal composite score (0–100) per listing: account age, feedback count, feedback ratio, price vs. market, category history
|
||||
- **Red flag detection** — hard flags for new accounts and established bad actors; soft flags for damage keywords, evasive language, duplicate photos, long-on-market listings, and significant price drops
|
||||
- **Price vs. market** — listing price compared against completed-sale medians via eBay Marketplace Insights API (Browse API fallback)
|
||||
- **Keyword filtering** — must-include (AND / ANY / OR-groups), must-exclude, category, price range; OR-groups expand into multiple targeted queries so eBay relevance doesn't silently drop variants
|
||||
- **Saved searches** — one-click re-run that restores all filter settings
|
||||
- **Background enrichment** — seller account age scraped via Playwright + Xvfb (Kasada/Cloudflare-safe headed Chromium); on-demand re-score per listing without re-searching
|
||||
- **LLM query builder** — describe what you want in plain language; an LLM builds the search terms (paid tier)
|
||||
- **Vision photo assessment** — condition scoring from listing photos via moondream2 locally or Claude vision (paid/cloud); VRAM-aware scheduling via circuitforge-core task scheduler
|
||||
- **Affiliate link builder** — eBay Partner Network wrapping with user BYOK support and per-retailer disclosure
|
||||
|
||||
### Platforms
|
||||
|
||||
| Platform | Search | Trust scoring | Completed-sale comps |
|
||||
|----------|--------|---------------|----------------------|
|
||||
| **eBay** | Browse API + Playwright fallback | All 5 signals | Marketplace Insights + Browse fallback |
|
||||
| **Mercari** | Playwright scraper | 3/5 signals (partial) | Phase 3 |
|
||||
| CT Bids, HiBid, AuctionZip, Invaluable, GovPlanet, Bidsquare, Proxibid | Planned | Planned | Planned |
|
||||
|
||||
### Auction sniping engine (roadmap)
|
||||
|
||||
- NTP-synchronized last-second bid submission
|
||||
- Soft-close detection and strategy adjustment
|
||||
- Proxy bid ladder with configurable max
|
||||
- Human approval gate before any bid executes
|
||||
- Post-win workflow: payment routing, shipping coordination, provenance documentation
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Requirements:** Docker with Compose plugin, Git. No API keys required to get started.
|
||||
**Requirements:** Docker with Compose plugin, Git. No API keys needed to get started.
|
||||
|
||||
```bash
|
||||
# One-line install — clones to ~/snipe by default
|
||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh)
|
||||
|
||||
# Or clone manually and run the script:
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
||||
bash snipe/install.sh
|
||||
```
|
||||
|
||||
Then open **http://localhost:8509**.
|
||||
|
||||
### Manual setup
|
||||
### Manual setup (if you prefer)
|
||||
|
||||
Snipe's API image builds from a parent context that includes `circuitforge-core`. Both repos must sit as siblings:
|
||||
Snipe's API image is built from a parent context that includes `circuitforge-core`. Both repos must sit as siblings in the same directory:
|
||||
|
||||
```
|
||||
workspace/
|
||||
|
|
@ -94,88 +36,236 @@ mkdir snipe-workspace && cd snipe-workspace
|
|||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
|
||||
cd snipe
|
||||
cp .env.example .env # add eBay API credentials if you have them (optional)
|
||||
cp .env.example .env # edit if you have eBay API credentials (optional)
|
||||
./manage.sh start
|
||||
```
|
||||
|
||||
### Optional: eBay API credentials
|
||||
|
||||
Snipe works without credentials using its Playwright scraper fallback. Adding credentials unlocks faster searches and inline seller account age without an extra scrape:
|
||||
Snipe works without any credentials using its Playwright scraper fallback. Adding eBay API credentials unlocks faster searches and inline seller account age (no extra scrape needed):
|
||||
|
||||
1. Register at [developer.ebay.com](https://developer.ebay.com/my/keys)
|
||||
2. Copy your Production **App ID** and **Cert ID** into `.env`
|
||||
3. `./manage.sh restart`
|
||||
3. Restart: `./manage.sh restart`
|
||||
|
||||
---
|
||||
|
||||
## Tiers
|
||||
## What it does
|
||||
|
||||
| Tier | What you get |
|
||||
|------|-------------|
|
||||
| **Free** | eBay + Mercari search, full trust scoring, keyword filtering, saved searches — local LLM only |
|
||||
| **Paid** | LLM query builder, background saved-search monitoring with alerts, cloud LLM option |
|
||||
| **Premium** | Vision photo condition assessment, fine-tuned trust models, multi-user |
|
||||
| **Ultra** | Human-in-the-loop operator — handles CAPTCHAs, phone calls, anything automation can't |
|
||||
Snipe has two layers that work together:
|
||||
|
||||
License key format: `CFG-SNPE-XXXX-XXXX-XXXX`
|
||||
**Layer 1 — Listing intelligence (MVP, implemented)**
|
||||
Before you bid, Snipe tells you whether a listing is worth your time. It fetches eBay listings, scores each seller's trustworthiness across five signals, flags suspicious pricing relative to completed sales, and surfaces red flags like new accounts, cosmetic damage buried in titles, and listings that have been sitting unsold for weeks.
|
||||
|
||||
**Layer 2 — Auction sniping (roadmap)**
|
||||
Snipe manages the bid itself: monitors listings across platforms, schedules last-second bids, handles soft-close extensions, and guides you through the post-win logistics (payment routing, shipping coordination, provenance documentation for antiques).
|
||||
|
||||
The name is the origin of the word "sniping" — common snipes are notoriously elusive birds, secretive and camouflaged, that flush suddenly from cover. Shooting one required extreme patience, stillness, and a precise last-second shot. That's the auction strategy.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
**Landing page — no account required**
|
||||

|
||||
|
||||
**Search results with trust scores**
|
||||

|
||||
|
||||
**STEAL badge — price significantly below market**
|
||||

|
||||
|
||||
> Red flag and Triple Red screenshots coming — captured opportunistically from real scammy listings.
|
||||
|
||||
---
|
||||
|
||||
## Implemented: eBay Listing Intelligence
|
||||
|
||||
### Search & filtering
|
||||
- Full-text eBay search via Browse API (with Playwright scraper fallback when no API credentials configured)
|
||||
- Price range, must-include keywords (AND / ANY / OR-groups mode), must-exclude terms, eBay category filter
|
||||
- OR-group mode expands keyword combinations into multiple targeted queries and deduplicates results — eBay relevance won't silently drop variants
|
||||
- Pages-to-fetch control: each Browse API page returns up to 200 listings
|
||||
- Saved searches with one-click re-run that restores all filter settings
|
||||
|
||||
### Seller trust scoring
|
||||
Five signals, each scored 0–20, composited to 0–100:
|
||||
|
||||
| Signal | What it measures |
|
||||
|--------|-----------------|
|
||||
| `account_age` | Days since eBay account registration |
|
||||
| `feedback_count` | Total feedback received |
|
||||
| `feedback_ratio` | Positive feedback percentage |
|
||||
| `price_vs_market` | Listing price vs. median of recent completed sales |
|
||||
| `category_history` | Whether seller has history selling in this category |
|
||||
|
||||
Scores are marked **partial** when signals are unavailable (e.g. account age not yet enriched). Partial scores are displayed with a visual indicator rather than penalizing the seller for missing data.
|
||||
|
||||
### Red flags
|
||||
Hard filters that override the composite score:
|
||||
- `new_account` — account registered within 7 days
|
||||
- `established_bad_actor` — feedback ratio < 80% with 20+ reviews
|
||||
|
||||
Soft flags surfaced as warnings:
|
||||
- `account_under_30_days` — account under 30 days old
|
||||
- `low_feedback_count` — fewer than 10 reviews
|
||||
- `suspicious_price` — listing price below 50% of market median *(suppressed automatically when the search returns a heterogeneous price distribution — e.g. mixed laptop generations — to prevent false positives)*
|
||||
- `duplicate_photo` — same image found on another listing (perceptual hash)
|
||||
- `scratch_dent_mentioned` — title keywords indicating cosmetic damage, functional problems, or evasive language (see below)
|
||||
- `long_on_market` — listing has been seen 5+ times over 14+ days without selling
|
||||
- `significant_price_drop` — current price more than 20% below first-seen price
|
||||
|
||||
### Scratch & dent title detection
|
||||
Scans listing titles for signals the item may have undisclosed damage or problems:
|
||||
- **Explicit damage**: scratch, scuff, dent, crack, chip, blemish, worn
|
||||
- **Condition catch-alls**: as is, for parts, parts only, spares or repair
|
||||
- **Evasive redirects**: "see description", "read description", "see photos for" (seller hiding damage detail in listing body)
|
||||
- **Functional problems**: "not working", "stopped working", "no power", "dead on arrival", "powers on but", "faulty", "broken screen/hinge/port"
|
||||
- **DIY/repair listings**: "needs repair", "needs tlc", "project laptop", "for repair", "sold as is"
|
||||
|
||||
### Seller enrichment
|
||||
- **Inline (API adapter)**: account age filled from Browse API `registrationDate` field
|
||||
- **Background (scraper)**: `/itm/` listing pages scraped for seller "Joined" date via Playwright + Xvfb (Kasada-safe headed Chromium)
|
||||
- **On-demand**: ↻ button on any listing card triggers `POST /api/enrich` — runs enrichment and re-scores without waiting for a second search
|
||||
- **Category history**: derived from the seller's accumulated listing data (Browse API `categories` field); improves with every search, no extra API calls
|
||||
|
||||
### Affiliate link builder
|
||||
|
||||
Listing cards surface eBay affiliate-wrapped URLs. Uses `circuitforge_core.affiliates.wrap_url` — resolution order: user opted out → plain URL; user has BYOK affiliate ID → their ID; CF env var set (`EBAY_AFFILIATE_ID`) → CF's ID; otherwise plain URL. Users can configure their own eBay Partner Network ID or opt out entirely in Settings.
|
||||
|
||||
Disclosure tooltip appears on first encounter per-session and on each wrapped link (per-retailer copy from `get_disclosure_text`).
|
||||
|
||||
### Feedback FAB
|
||||
|
||||
In-app feedback button (bottom-right FAB) opens a modal: title, description, optional screenshot. Posts to the CF feedback endpoint. Status probed on load; FAB hidden if endpoint unreachable.
|
||||
|
||||
### Vision task scheduling
|
||||
|
||||
Photo condition assessment tasks queued through `circuitforge_core.tasks.TaskScheduler` — VRAM-aware slot management shared with any other LLM workloads on the same host. Runs moondream2 locally (free tier) or Claude vision (paid/cloud). Results stored per-listing and update the trust score card.
|
||||
|
||||
### Market price comparison
|
||||
Completed sales fetched via eBay Marketplace Insights API (with Browse API fallback for app tiers that don't have Insights access). Median stored per query hash, used to score `price_vs_market` across all listings in a search.
|
||||
|
||||
### Adapters
|
||||
| Adapter | When used | Signals available |
|
||||
|---------|-----------|-------------------|
|
||||
| Browse API (`api`) | eBay API credentials configured | All signals; account age inline |
|
||||
| Playwright scraper (`scraper`) | No credentials / forced | All signals except account age (async BTF enrichment) |
|
||||
| `auto` (default) | — | API if credentials present, scraper otherwise |
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech | Port |
|
||||
|-------|------|------|
|
||||
| Frontend | Vue 3 + Pinia + UnoCSS + Vite (nginx) | 8509 |
|
||||
| API | FastAPI (uvicorn) | 8510 |
|
||||
| Scraper | Playwright + playwright-stealth + Xvfb | — |
|
||||
| DB | SQLite (`data/snipe.db`) | — |
|
||||
| Core | circuitforge-core (editable install) | — |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
./manage.sh start # start all services
|
||||
./manage.sh stop # stop
|
||||
./manage.sh restart # restart
|
||||
./manage.sh logs # tail logs
|
||||
./manage.sh open # open in browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology | Port |
|
||||
|-------|-----------|------|
|
||||
| Frontend | Vue 3 + Pinia + UnoCSS + Vite (served via nginx) | 8509 |
|
||||
| API | FastAPI (uvicorn) | 8510 |
|
||||
| Scraper | Playwright + playwright-stealth + Xvfb (Kasada/Cloudflare-safe headed Chromium) | — |
|
||||
| Database | SQLite (`data/snipe.db`) | — |
|
||||
| Core | circuitforge-core (editable install) | — |
|
||||
|
||||
The scraper stack uses headed Chromium via Xvfb (X virtual framebuffer) with playwright-stealth for all platform access. Headless and `requests`-based approaches are blocked by eBay and Mercari.
|
||||
Cloud stack (shared DB, multi-user):
|
||||
```bash
|
||||
docker compose -f compose.cloud.yml -p snipe-cloud up -d
|
||||
docker compose -f compose.cloud.yml -p snipe-cloud build api # after Python changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
## Roadmap
|
||||
|
||||
Full documentation at **[docs.circuitforge.tech/snipe](https://docs.circuitforge.tech/snipe)** — setup guide, trust scoring algorithm, platform adapter reference, API docs, and self-hosting notes.
|
||||
### Near-term (eBay)
|
||||
|
||||
| Issue | Feature |
|
||||
|-------|---------|
|
||||
| [#1](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/1) | SSE/WebSocket live score push — enriched data appears without re-search |
|
||||
| [#2](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/2) | eBay OAuth (Connect eBay Account) for full trust score access via Trading API |
|
||||
| [#4](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/4) | Scammer database: community blocklist + batch eBay Trust & Safety reporting |
|
||||
| [#5](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/5) | UPC/product lookup → LLM-crafted search terms (paid tier) |
|
||||
| [#8](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/8) | "Triple Red" easter egg: CSS animation when all hard flags fire simultaneously |
|
||||
| [#11](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/11) | Vision-based photo condition assessment — moondream2 (local) / Claude vision (cloud, paid) |
|
||||
| [#12](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/12) | Background saved-search monitoring with configurable alerts |
|
||||
|
||||
### Cloud / infrastructure
|
||||
|
||||
| Issue | Feature |
|
||||
|-------|---------|
|
||||
| [#6](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/6) | Shared seller/scammer/comps DB across cloud users (public data, no re-scraping) |
|
||||
| [#7](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7) | Shared image hash DB — requires explicit opt-in consent (CF privacy-by-architecture) |
|
||||
|
||||
### Auction sniping engine
|
||||
|
||||
| Issue | Feature |
|
||||
|-------|---------|
|
||||
| [#9](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/9) | Bid scheduling + snipe execution (NTP-synchronized, soft-close handling, human approval gate) |
|
||||
| [#13](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/13) | Post-win workflow: payment routing, shipping coordination, provenance documentation |
|
||||
|
||||
### Multi-platform expansion
|
||||
|
||||
| Issue | Feature |
|
||||
|-------|---------|
|
||||
| [#10](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/10) | CT Bids, HiBid, AuctionZip, Invaluable, GovPlanet, Bidsquare, Proxibid |
|
||||
|
||||
---
|
||||
|
||||
## Forgejo-primary
|
||||
## Primary platforms (full vision)
|
||||
|
||||
Snipe is developed and maintained on Forgejo at [git.opensourcesolarpunk.com/Circuit-Forge/snipe](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe). GitHub and Codeberg are read-only mirrors. File issues and submit pull requests on Forgejo.
|
||||
- **eBay** — general + collectibles *(search + trust scoring: implemented)*
|
||||
- **CT Bids** — Connecticut state surplus and municipal auctions
|
||||
- **GovPlanet / IronPlanet** — government surplus equipment
|
||||
- **AuctionZip** — antique auction house aggregator (1,000+ houses)
|
||||
- **Invaluable / LiveAuctioneers** — fine art and antiques
|
||||
- **Bidsquare** — antiques and collectibles
|
||||
- **HiBid** — estate auctions
|
||||
- **Proxibid** — industrial and collector auctions
|
||||
|
||||
---
|
||||
## Why auctions are hard
|
||||
|
||||
## Contributing
|
||||
Online auctions are frustrating because:
|
||||
- Winning requires being present at the exact closing moment — sometimes 2 AM
|
||||
- Platforms vary wildly: some allow proxy bids, some don't; closing times extend on activity
|
||||
- Scammers exploit auction urgency — new accounts, stolen photos, pressure to pay outside platform
|
||||
- Price history is hidden — you don't know if an item is underpriced or a trap
|
||||
- Sellers hide damage in descriptions rather than titles to avoid automated filters
|
||||
- Shipping logistics for large / fragile antiques require coordination with the auction house
|
||||
- Provenance documentation is inconsistent across auction houses
|
||||
|
||||
Bug reports and feature requests: open an issue on Forgejo. The discovery pipeline (scrapers, adapters, signal extraction) is MIT-licensed — pull requests welcome. AI trust-scoring features are BSL 1.1 — contributions are accepted but the license terms apply.
|
||||
## Bidding strategy engine (planned)
|
||||
|
||||
---
|
||||
- **Hard snipe**: submit bid N seconds before close (default: 8s)
|
||||
- **Soft-close handling**: detect if platform extends on last-minute bids; adjust strategy
|
||||
- **Proxy ladder**: set max and let the engine bid in increments, reserve snipe for final window
|
||||
- **Reserve detection**: identify likely reserve price from bid history patterns
|
||||
- **Comparable sales**: pull recent auction results for same/similar items across platforms
|
||||
|
||||
## License
|
||||
## Post-win workflow (planned)
|
||||
|
||||
Snipe uses a dual license:
|
||||
1. Payment method routing (platform-specific: CC, wire, check)
|
||||
2. Shipping quote requests to approved carriers (freight / large items via uShip; parcel via FedEx/UPS)
|
||||
3. Condition report request from auction house
|
||||
4. Provenance packet generation (for antiques / fine art resale or insurance)
|
||||
5. Add to inventory (for dealers / collectors tracking portfolio value)
|
||||
|
||||
| Component | License |
|
||||
|-----------|---------|
|
||||
| Discovery pipeline — scrapers, platform adapters, search, keyword filtering | [MIT](LICENSE-MIT) |
|
||||
| LLM trust-scoring, query builder, vision assessment, AI features | [BSL 1.1](LICENSE-BSL) — free for personal non-commercial self-hosting; commercial use requires a paid license; converts to MIT after 4 years |
|
||||
## Product code (license key)
|
||||
|
||||
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)
|
||||
`CFG-SNPE-XXXX-XXXX-XXXX`
|
||||
|
||||
Privacy · Safety · Accessibility — co-equal, non-negotiable.
|
||||
## Tech notes
|
||||
|
||||
[circuitforge.tech](https://circuitforge.tech)
|
||||
- Shared `circuitforge-core` scaffold (DB, LLM router, tier system, config)
|
||||
- Platform adapters: currently eBay only; AuctionZip, Invaluable, HiBid, CT Bids planned (Playwright + API where available)
|
||||
- Bid execution: Playwright automation with precise timing (NTP-synchronized)
|
||||
- Soft-close detection: platform-specific rules engine
|
||||
- Comparable sales: eBay completed listings via Marketplace Insights API + Browse API fallback
|
||||
- Vision module: condition assessment from listing photos — moondream2 / Claude vision (paid tier stub in `app/trust/photo.py`)
|
||||
- **Kasada bypass**: headed Chromium via Xvfb; all scraping uses this path — headless and `requests`-based approaches are blocked by eBay
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Cloud session resolution for Snipe FastAPI.
|
||||
|
||||
Delegates JWT validation, Heimdall provisioning, tier resolution, and guest
|
||||
session management to circuitforge_core.CloudSessionFactory. Snipe-specific
|
||||
CloudUser (shared_db + user_db paths), SessionFeatures, and DB helpers are
|
||||
kept here.
|
||||
In local mode (CLOUD_MODE unset/false): all functions return a local CloudUser
|
||||
with no auth checks, full tier access, and both DB paths pointing to SNIPE_DB.
|
||||
|
||||
In cloud mode (CLOUD_MODE=true): validates the cf_session JWT injected by Caddy
|
||||
as X-CF-Session, resolves user_id, auto-provisions a free Heimdall license on
|
||||
first visit, fetches the tier, and returns per-user DB paths.
|
||||
|
||||
FastAPI usage:
|
||||
@app.get("/api/search")
|
||||
|
|
@ -16,12 +18,15 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
import jwt as pyjwt
|
||||
import requests
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -29,12 +34,19 @@ log = logging.getLogger(__name__)
|
|||
|
||||
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
|
||||
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/snipe-cloud-data"))
|
||||
DIRECTUS_JWT_SECRET: str = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||
CF_SERVER_SECRET: str = os.environ.get("CF_SERVER_SECRET", "")
|
||||
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
||||
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
||||
|
||||
# Local-mode DB paths (ignored in cloud mode)
|
||||
_LOCAL_SNIPE_DB: Path = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
||||
|
||||
TIERS = ["free", "paid", "premium", "ultra"]
|
||||
# Tier cache: user_id → (tier, fetched_at_epoch)
|
||||
_TIER_CACHE: dict[str, tuple[str, float]] = {}
|
||||
_TIER_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
_core = _CoreFactory(product="snipe")
|
||||
TIERS = ["free", "paid", "premium", "ultra"]
|
||||
|
||||
|
||||
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -78,6 +90,97 @@ def compute_features(tier: str) -> SessionFeatures:
|
|||
)
|
||||
|
||||
|
||||
# ── JWT validation ────────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_session_token(header_value: str) -> str:
|
||||
"""Extract cf_session value from a Cookie or X-CF-Session header string.
|
||||
|
||||
Returns the JWT token string, or "" if no valid session token is found.
|
||||
Cookie strings like "snipe_guest=abc123" (no cf_session key) return ""
|
||||
so the caller falls through to the guest/anonymous path rather than
|
||||
passing a non-JWT string to validate_session_jwt().
|
||||
"""
|
||||
m = re.search(r'(?:^|;)\s*cf_session=([^;]+)', header_value)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
# Only treat as a raw JWT if it has exactly three base64url segments (header.payload.sig).
|
||||
# Cookie strings like "snipe_guest=abc123" must NOT be forwarded to JWT validation.
|
||||
stripped = header_value.strip()
|
||||
if re.match(r'^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_=]+$', stripped):
|
||||
return stripped # bare JWT forwarded directly by Caddy
|
||||
return "" # not a JWT and no cf_session cookie — treat as unauthenticated
|
||||
|
||||
|
||||
def _extract_guest_token(cookie_header: str) -> str | None:
|
||||
"""Extract snipe_guest UUID from the Cookie header, if present."""
|
||||
m = re.search(r'(?:^|;)\s*snipe_guest=([^;]+)', cookie_header)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def validate_session_jwt(token: str) -> str:
|
||||
"""Validate a cf_session JWT and return the Directus user_id.
|
||||
|
||||
Uses HMAC-SHA256 verification against DIRECTUS_JWT_SECRET (same secret
|
||||
cf-directus uses to sign session tokens). Returns user_id on success,
|
||||
raises HTTPException(401) on failure.
|
||||
|
||||
Directus 11+ uses 'id' (not 'sub') for the user UUID in its JWT payload.
|
||||
"""
|
||||
try:
|
||||
payload = pyjwt.decode(
|
||||
token,
|
||||
DIRECTUS_JWT_SECRET,
|
||||
algorithms=["HS256"],
|
||||
options={"require": ["id", "exp"]},
|
||||
)
|
||||
return payload["id"]
|
||||
except Exception as exc:
|
||||
log.debug("JWT validation failed: %s", exc)
|
||||
raise HTTPException(status_code=401, detail="Session invalid or expired")
|
||||
|
||||
|
||||
# ── Heimdall integration ──────────────────────────────────────────────────────
|
||||
|
||||
def _ensure_provisioned(user_id: str) -> None:
|
||||
"""Idempotent: create a free Heimdall license for this user if none exists."""
|
||||
if not HEIMDALL_ADMIN_TOKEN:
|
||||
return
|
||||
try:
|
||||
requests.post(
|
||||
f"{HEIMDALL_URL}/admin/provision",
|
||||
json={"directus_user_id": user_id, "product": "snipe", "tier": "free"},
|
||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
|
||||
|
||||
|
||||
def _fetch_cloud_tier(user_id: str) -> str:
|
||||
"""Resolve tier from Heimdall with a 5-minute in-process cache."""
|
||||
now = time.monotonic()
|
||||
cached = _TIER_CACHE.get(user_id)
|
||||
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
if not HEIMDALL_ADMIN_TOKEN:
|
||||
return "free"
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{HEIMDALL_URL}/admin/cloud/resolve",
|
||||
json={"directus_user_id": user_id, "product": "snipe"},
|
||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||
timeout=5,
|
||||
)
|
||||
tier = resp.json().get("tier", "free") if resp.ok else "free"
|
||||
except Exception as exc:
|
||||
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
||||
tier = "free"
|
||||
|
||||
_TIER_CACHE[user_id] = (tier, now)
|
||||
return tier
|
||||
|
||||
|
||||
# ── DB path helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _shared_db_path() -> Path:
|
||||
|
|
@ -106,25 +209,58 @@ def _anon_db_path() -> Path:
|
|||
|
||||
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
||||
|
||||
def get_session(request: Request, response: Response) -> CloudUser:
|
||||
def get_session(request: Request) -> CloudUser:
|
||||
"""FastAPI dependency — resolves the current user from the request.
|
||||
|
||||
Delegates auth/tier resolution to cf-core CloudSessionFactory, then maps
|
||||
the result to Snipe's CloudUser with shared_db + user_db paths.
|
||||
|
||||
Local mode: fully-privileged "local" user pointing at SNIPE_DB.
|
||||
Local mode: returns a fully-privileged "local" user pointing at SNIPE_DB.
|
||||
Cloud mode: validates X-CF-Session JWT, provisions Heimdall license,
|
||||
resolves tier, returns per-user DB paths.
|
||||
Anonymous: guest session with free-tier access to shared scammer corpus.
|
||||
Unauthenticated cloud visitors: returns a free-tier anonymous user so
|
||||
search and scoring work without an account.
|
||||
"""
|
||||
core_user = _core.resolve(request, response)
|
||||
uid, tier = core_user.user_id, core_user.tier
|
||||
if not CLOUD_MODE:
|
||||
return CloudUser(
|
||||
user_id="local",
|
||||
tier="local",
|
||||
shared_db=_LOCAL_SNIPE_DB,
|
||||
user_db=_LOCAL_SNIPE_DB,
|
||||
)
|
||||
|
||||
if not CLOUD_MODE or uid in ("local", "local-dev"):
|
||||
return CloudUser(user_id=uid, tier=tier, shared_db=_LOCAL_SNIPE_DB, user_db=_LOCAL_SNIPE_DB)
|
||||
if uid.startswith("anon-"):
|
||||
return CloudUser(user_id=uid, tier=tier, shared_db=_shared_db_path(), user_db=_anon_db_path())
|
||||
return CloudUser(user_id=uid, tier=tier, shared_db=_shared_db_path(), user_db=_user_db_path(uid))
|
||||
cookie_header = request.headers.get("cookie", "")
|
||||
raw_header = request.headers.get("x-cf-session", "") or cookie_header
|
||||
|
||||
if not raw_header:
|
||||
# No session at all — check for a guest UUID cookie set by /api/session
|
||||
guest_uuid = _extract_guest_token(cookie_header)
|
||||
user_id = f"guest:{guest_uuid}" if guest_uuid else "anonymous"
|
||||
return CloudUser(
|
||||
user_id=user_id,
|
||||
tier="free",
|
||||
shared_db=_shared_db_path(),
|
||||
user_db=_anon_db_path(),
|
||||
)
|
||||
|
||||
token = _extract_session_token(raw_header)
|
||||
if not token:
|
||||
guest_uuid = _extract_guest_token(cookie_header)
|
||||
user_id = f"guest:{guest_uuid}" if guest_uuid else "anonymous"
|
||||
return CloudUser(
|
||||
user_id=user_id,
|
||||
tier="free",
|
||||
shared_db=_shared_db_path(),
|
||||
user_db=_anon_db_path(),
|
||||
)
|
||||
|
||||
user_id = validate_session_jwt(token)
|
||||
_ensure_provisioned(user_id)
|
||||
tier = _fetch_cloud_tier(user_id)
|
||||
|
||||
return CloudUser(
|
||||
user_id=user_id,
|
||||
tier=tier,
|
||||
shared_db=_shared_db_path(),
|
||||
user_db=_user_db_path(user_id),
|
||||
)
|
||||
|
||||
|
||||
def require_tier(min_tier: str):
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from app.db.store import Store
|
||||
from app.platforms.ebay.auth import EbayTokenManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -41,24 +40,6 @@ router = APIRouter()
|
|||
|
||||
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
||||
|
||||
# ── App-level token manager ───────────────────────────────────────────────────
|
||||
# Lazily initialized from env vars; shared across all webhook requests.
|
||||
# The Notification public_key endpoint requires a Bearer app token.
|
||||
_app_token_manager: EbayTokenManager | None = None
|
||||
|
||||
|
||||
def _get_app_token() -> str | None:
|
||||
"""Return a valid eBay app-level Bearer token, or None if creds are absent."""
|
||||
global _app_token_manager
|
||||
client_id = (os.environ.get("EBAY_APP_ID") or os.environ.get("EBAY_CLIENT_ID", "")).strip()
|
||||
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
|
||||
if not client_id or not client_secret:
|
||||
return None
|
||||
if _app_token_manager is None:
|
||||
_app_token_manager = EbayTokenManager(client_id, client_secret)
|
||||
return _app_token_manager.get_token()
|
||||
|
||||
|
||||
# ── Public-key cache ──────────────────────────────────────────────────────────
|
||||
# eBay key rotation is rare; 1-hour TTL is appropriate.
|
||||
_KEY_CACHE_TTL = 3600
|
||||
|
|
@ -77,14 +58,7 @@ def _fetch_public_key(kid: str) -> bytes:
|
|||
return cached[0]
|
||||
|
||||
key_url = _EBAY_KEY_URL.format(kid=kid)
|
||||
headers: dict[str, str] = {}
|
||||
app_token = _get_app_token()
|
||||
if app_token:
|
||||
headers["Authorization"] = f"Bearer {app_token}"
|
||||
else:
|
||||
log.warning("public_key fetch: no app credentials — request will likely fail")
|
||||
|
||||
resp = requests.get(key_url, headers=headers, timeout=10)
|
||||
resp = requests.get(key_url, timeout=10)
|
||||
if not resp.ok:
|
||||
log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500])
|
||||
resp.raise_for_status()
|
||||
|
|
@ -94,42 +68,6 @@ def _fetch_public_key(kid: str) -> bytes:
|
|||
return pem_bytes
|
||||
|
||||
|
||||
# ── GET — webhook health check ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/ebay/webhook-health")
|
||||
def ebay_webhook_health() -> dict:
|
||||
"""Lightweight health check for eBay webhook compliance monitoring.
|
||||
|
||||
Returns 200 + status dict when the webhook is fully configured.
|
||||
Returns 500 when required env vars are missing.
|
||||
Intended for Uptime Kuma or similar uptime monitors.
|
||||
"""
|
||||
token = os.environ.get("EBAY_NOTIFICATION_TOKEN", "")
|
||||
endpoint = os.environ.get("EBAY_NOTIFICATION_ENDPOINT", "")
|
||||
client_id = (os.environ.get("EBAY_APP_ID") or os.environ.get("EBAY_CLIENT_ID", "")).strip()
|
||||
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
|
||||
|
||||
missing = [
|
||||
name for name, val in [
|
||||
("EBAY_NOTIFICATION_TOKEN", token),
|
||||
("EBAY_NOTIFICATION_ENDPOINT", endpoint),
|
||||
("EBAY_APP_ID / EBAY_CLIENT_ID", client_id),
|
||||
("EBAY_CERT_ID / EBAY_CLIENT_SECRET", client_secret),
|
||||
] if not val
|
||||
]
|
||||
if missing:
|
||||
log.error("ebay_webhook_health: missing config: %s", missing)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Webhook misconfigured — missing: {missing}",
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"endpoint": endpoint,
|
||||
"signature_verification": os.environ.get("EBAY_WEBHOOK_VERIFY_SIGNATURES", "true"),
|
||||
}
|
||||
|
||||
|
||||
# ── GET — challenge verification ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/ebay/account-deletion")
|
||||
|
|
|
|||
1413
api/main.py
1413
api/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS reported_sellers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
platform_seller_id TEXT NOT NULL,
|
||||
username TEXT,
|
||||
reported_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
reported_by TEXT NOT NULL DEFAULT 'user', -- user | bulk_action
|
||||
UNIQUE(platform, platform_seller_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reported_sellers_lookup
|
||||
ON reported_sellers(platform, platform_seller_id);
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
-- Migration 013: eBay user OAuth tokens
|
||||
--
|
||||
-- Stores per-user eBay Authorization Code tokens so the app can call
|
||||
-- Trading API GetUser for instant account_age_days + category feedback
|
||||
-- without Playwright scraping.
|
||||
--
|
||||
-- Stored in the per-user DB (user.db), never the shared DB.
|
||||
-- access_token is short-lived (2h); refresh_token is valid 18 months.
|
||||
-- The API layer refreshes access_token automatically before expiry.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ebay_user_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- Single row per user DB — upsert on reconnect
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL, -- epoch seconds; access token expiry
|
||||
scopes TEXT NOT NULL DEFAULT '',
|
||||
connected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_refreshed TEXT
|
||||
);
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
-- Migration 014: background monitor settings on saved_searches + watch_alerts table
|
||||
|
||||
ALTER TABLE saved_searches ADD COLUMN monitor_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE saved_searches ADD COLUMN poll_interval_min INTEGER NOT NULL DEFAULT 60;
|
||||
ALTER TABLE saved_searches ADD COLUMN min_trust_score INTEGER NOT NULL DEFAULT 60;
|
||||
ALTER TABLE saved_searches ADD COLUMN last_checked_at TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS watch_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
saved_search_id INTEGER NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE,
|
||||
platform_listing_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
trust_score INTEGER NOT NULL,
|
||||
url TEXT,
|
||||
first_alerted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
dismissed_at TEXT,
|
||||
UNIQUE(saved_search_id, platform_listing_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watch_alerts_undismissed
|
||||
ON watch_alerts(saved_search_id)
|
||||
WHERE dismissed_at IS NULL;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
-- Migration 015: cross-user monitor registry for the background polling loop
|
||||
--
|
||||
-- In cloud mode this table lives in shared.db — the polling loop queries it
|
||||
-- to find all due monitors without scanning per-user DB files.
|
||||
-- In local mode it lives in the single local DB (same result, one user).
|
||||
--
|
||||
-- user_db_path references the per-user snipe user.db so the poller knows
|
||||
-- which DB to open for the full SavedSearch config and to write alerts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS active_monitors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_db_path TEXT NOT NULL,
|
||||
saved_search_id INTEGER NOT NULL,
|
||||
poll_interval_min INTEGER NOT NULL DEFAULT 60,
|
||||
last_checked_at TEXT,
|
||||
UNIQUE(user_db_path, saved_search_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_active_monitors_due
|
||||
ON active_monitors(last_checked_at);
|
||||
|
|
@ -81,26 +81,6 @@ class SavedSearch:
|
|||
id: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
last_run_at: Optional[str] = None
|
||||
# Monitor settings (migration 014)
|
||||
monitor_enabled: bool = False
|
||||
poll_interval_min: int = 60
|
||||
min_trust_score: int = 60
|
||||
last_checked_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatchAlert:
|
||||
"""A new listing surfaced by the background monitor for a saved search."""
|
||||
saved_search_id: int
|
||||
platform_listing_id: str
|
||||
title: str
|
||||
price: float
|
||||
trust_score: int
|
||||
currency: str = "USD"
|
||||
url: Optional[str] = None
|
||||
id: Optional[int] = None
|
||||
first_alerted_at: Optional[str] = None
|
||||
dismissed_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
-- Snipe shared tables: sellers, market_comps, reported_sellers
|
||||
-- Replaces the equivalent tables in shared.db (SQLite).
|
||||
-- Per-user tables (listings, trust_scores, saved_searches) remain in SQLite.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sellers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform TEXT NOT NULL,
|
||||
platform_seller_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
account_age_days INTEGER,
|
||||
feedback_count INTEGER NOT NULL DEFAULT 0,
|
||||
feedback_ratio DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
category_history_json TEXT NOT NULL DEFAULT '{}',
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (platform, platform_seller_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_comps (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform TEXT NOT NULL,
|
||||
query_hash TEXT NOT NULL,
|
||||
median_price DOUBLE PRECISION NOT NULL,
|
||||
sample_count INTEGER NOT NULL,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (platform, query_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reported_sellers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform TEXT NOT NULL,
|
||||
platform_seller_id TEXT NOT NULL,
|
||||
username TEXT,
|
||||
reported_by TEXT NOT NULL DEFAULT 'user',
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (platform, platform_seller_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scammer_blocklist (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform TEXT NOT NULL,
|
||||
platform_seller_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (platform, platform_seller_id)
|
||||
);
|
||||
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.pool import ThreadedConnectionPool
|
||||
|
||||
from app.db.models import MarketComp, ScammerEntry, Seller
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_MIN_CONN = 2
|
||||
_MAX_CONN = 20
|
||||
|
||||
|
||||
class SnipeSharedDB:
|
||||
"""Thread-safe Postgres connection pool for Snipe shared tables."""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
self._pool = ThreadedConnectionPool(_MIN_CONN, _MAX_CONN, dsn=dsn)
|
||||
|
||||
def getconn(self):
|
||||
return self._pool.getconn()
|
||||
|
||||
def putconn(self, conn) -> None:
|
||||
self._pool.putconn(conn)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.closeall()
|
||||
|
||||
def run_migrations(self) -> None:
|
||||
"""Apply pg_migrations/*.sql in filename order. Idempotent."""
|
||||
migrations_dir = Path(__file__).parent / "pg_migrations"
|
||||
files = sorted(migrations_dir.glob("*.sql"), key=lambda p: p.name)
|
||||
|
||||
conn = self.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS _snipe_shared_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
for f in files:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM _snipe_shared_migrations WHERE filename = %s",
|
||||
(f.name,),
|
||||
)
|
||||
if cur.fetchone():
|
||||
continue
|
||||
log.info("Applying migration: %s", f.name)
|
||||
cur.execute(f.read_text())
|
||||
cur.execute(
|
||||
"INSERT INTO _snipe_shared_migrations (filename) VALUES (%s)",
|
||||
(f.name,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.putconn(conn)
|
||||
|
||||
|
||||
class SnipeSharedStore:
|
||||
"""Postgres-backed store for sellers, market_comps, and reported_sellers.
|
||||
|
||||
Satisfies SharedTableProtocol. clone() returns self — ThreadedConnectionPool
|
||||
is already thread-safe, so no new instance is needed per thread.
|
||||
"""
|
||||
|
||||
def __init__(self, db: SnipeSharedDB) -> None:
|
||||
self._db = db
|
||||
|
||||
def clone(self) -> "SnipeSharedStore":
|
||||
return self
|
||||
|
||||
# Sellers
|
||||
|
||||
def save_seller(self, seller: Seller) -> None:
|
||||
self.save_sellers([seller])
|
||||
|
||||
def save_sellers(self, sellers: list[Seller]) -> None:
|
||||
if not sellers:
|
||||
return
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO sellers
|
||||
(platform, platform_seller_id, username, account_age_days,
|
||||
feedback_count, feedback_ratio, category_history_json)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (platform, platform_seller_id) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
feedback_count = EXCLUDED.feedback_count,
|
||||
feedback_ratio = EXCLUDED.feedback_ratio,
|
||||
account_age_days = COALESCE(
|
||||
EXCLUDED.account_age_days,
|
||||
sellers.account_age_days
|
||||
),
|
||||
category_history_json = COALESCE(
|
||||
NULLIF(NULLIF(EXCLUDED.category_history_json, '{}'), ''),
|
||||
NULLIF(NULLIF(sellers.category_history_json, '{}'), ''),
|
||||
'{}'
|
||||
),
|
||||
fetched_at = NOW()
|
||||
""",
|
||||
[
|
||||
(s.platform, s.platform_seller_id, s.username, s.account_age_days,
|
||||
s.feedback_count, s.feedback_ratio, s.category_history_json or "{}")
|
||||
for s in sellers
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def get_seller(self, platform: str, platform_seller_id: str) -> Optional[Seller]:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT platform, platform_seller_id, username, account_age_days,
|
||||
feedback_count, feedback_ratio, category_history_json,
|
||||
id, fetched_at
|
||||
FROM sellers
|
||||
WHERE platform = %s AND platform_seller_id = %s
|
||||
""",
|
||||
(platform, platform_seller_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Seller(*row[:7], id=row[7], fetched_at=str(row[8]))
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def delete_seller_data(self, platform: str, platform_seller_id: str) -> None:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM sellers WHERE platform = %s AND platform_seller_id = %s",
|
||||
(platform, platform_seller_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
# MarketComps
|
||||
|
||||
def save_market_comp(self, comp: MarketComp) -> None:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO market_comps
|
||||
(platform, query_hash, median_price, sample_count, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s::TIMESTAMPTZ)
|
||||
ON CONFLICT (platform, query_hash) DO UPDATE SET
|
||||
median_price = EXCLUDED.median_price,
|
||||
sample_count = EXCLUDED.sample_count,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
fetched_at = NOW()
|
||||
""",
|
||||
(comp.platform, comp.query_hash, comp.median_price,
|
||||
comp.sample_count, comp.expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT platform, query_hash, median_price, sample_count,
|
||||
expires_at, id, fetched_at
|
||||
FROM market_comps
|
||||
WHERE platform = %s AND query_hash = %s AND expires_at > %s::TIMESTAMPTZ
|
||||
""",
|
||||
(platform, query_hash, now),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return MarketComp(*row[:5], id=row[5], fetched_at=str(row[6]))
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
# Reported Sellers
|
||||
|
||||
def mark_reported(
|
||||
self,
|
||||
platform: str,
|
||||
platform_seller_id: str,
|
||||
username: Optional[str] = None,
|
||||
reported_by: str = "user",
|
||||
) -> None:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO reported_sellers
|
||||
(platform, platform_seller_id, username, reported_by)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (platform, platform_seller_id) DO NOTHING
|
||||
""",
|
||||
(platform, platform_seller_id, username, reported_by),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def list_reported(self, platform: str = "ebay") -> list[str]:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT platform_seller_id FROM reported_sellers WHERE platform = %s",
|
||||
(platform,),
|
||||
)
|
||||
return [row[0] for row in cur.fetchall()]
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
# Seller Category Refresh
|
||||
|
||||
def refresh_seller_categories(
|
||||
self,
|
||||
platform: str,
|
||||
seller_ids: list[str],
|
||||
listing_store=None, # always a SQLite Store in practice
|
||||
) -> int:
|
||||
"""Derive category_history_json from listing data and update sellers in Postgres.
|
||||
|
||||
listing_store must be provided (it's always the per-user SQLite Store).
|
||||
Returns count of sellers updated.
|
||||
"""
|
||||
from app.platforms.ebay.scraper import _classify_category_label # lazy to avoid circular
|
||||
import json
|
||||
|
||||
if not seller_ids or listing_store is None:
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
for sid in seller_ids:
|
||||
seller = self.get_seller(platform, sid)
|
||||
if not seller or seller.category_history_json not in ("{}", "", None):
|
||||
continue
|
||||
# listing_store is always a SQLite Store; access _conn directly for the query.
|
||||
rows = listing_store._conn.execute(
|
||||
"SELECT category_name, COUNT(*) FROM listings "
|
||||
"WHERE platform=? AND seller_platform_id=? AND category_name IS NOT NULL "
|
||||
"GROUP BY category_name",
|
||||
(platform, sid),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
continue
|
||||
counts: dict[str, int] = {}
|
||||
for cat_name, cnt in rows:
|
||||
key = _classify_category_label(cat_name)
|
||||
if key:
|
||||
counts[key] = counts.get(key, 0) + cnt
|
||||
if counts:
|
||||
from dataclasses import replace
|
||||
self.save_sellers([replace(seller, category_history_json=json.dumps(counts))])
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
# Scammer Blocklist
|
||||
|
||||
def is_blocklisted(self, platform: str, platform_seller_id: str) -> bool:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM scammer_blocklist "
|
||||
"WHERE platform = %s AND platform_seller_id = %s LIMIT 1",
|
||||
(platform, platform_seller_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO scammer_blocklist
|
||||
(platform, platform_seller_id, username, reason, source)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (platform, platform_seller_id) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
reason = COALESCE(EXCLUDED.reason, scammer_blocklist.reason),
|
||||
source = EXCLUDED.source
|
||||
""",
|
||||
(entry.platform, entry.platform_seller_id, entry.username,
|
||||
entry.reason, entry.source),
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute(
|
||||
"SELECT id, created_at FROM scammer_blocklist "
|
||||
"WHERE platform = %s AND platform_seller_id = %s",
|
||||
(entry.platform, entry.platform_seller_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
from dataclasses import replace
|
||||
return replace(entry, id=row[0], created_at=str(row[1]))
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def remove_from_blocklist(self, platform: str, platform_seller_id: str) -> None:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM scammer_blocklist "
|
||||
"WHERE platform = %s AND platform_seller_id = %s",
|
||||
(platform, platform_seller_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def list_blocklist(self, platform: str = "ebay") -> list[ScammerEntry]:
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT platform, platform_seller_id, username, reason, source, id, created_at
|
||||
FROM scammer_blocklist
|
||||
WHERE platform = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(platform,),
|
||||
)
|
||||
return [
|
||||
ScammerEntry(
|
||||
platform=r[0], platform_seller_id=r[1], username=r[2],
|
||||
reason=r[3], source=r[4], id=r[5], created_at=str(r[6]),
|
||||
)
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
"""Protocol (duck-type interface) for shared table backends (SQLite and Postgres)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol, runtime_checkable
|
||||
|
||||
from app.db.models import MarketComp, ScammerEntry, Seller
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SharedTableProtocol(Protocol):
|
||||
"""Protocol that both Store (SQLite) and SnipeSharedStore (Postgres) must satisfy.
|
||||
|
||||
This enables code that reads/writes shared tables (sellers, market_comps,
|
||||
reported_sellers, scammer_blocklist) to remain agnostic to the underlying backend.
|
||||
"""
|
||||
|
||||
def save_seller(self, seller: Seller) -> None:
|
||||
"""Persist a single seller record."""
|
||||
...
|
||||
|
||||
def save_sellers(self, sellers: list[Seller]) -> None:
|
||||
"""Persist multiple seller records (batch upsert)."""
|
||||
...
|
||||
|
||||
def get_seller(self, platform: str, platform_seller_id: str) -> Optional[Seller]:
|
||||
"""Fetch a single seller by platform and platform_seller_id."""
|
||||
...
|
||||
|
||||
def save_market_comp(self, comp: MarketComp) -> None:
|
||||
"""Persist a market comparison record."""
|
||||
...
|
||||
|
||||
def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]:
|
||||
"""Fetch a market comparison by platform and query_hash."""
|
||||
...
|
||||
|
||||
def mark_reported(
|
||||
self,
|
||||
platform: str,
|
||||
platform_seller_id: str,
|
||||
username: Optional[str] = None,
|
||||
reported_by: str = "user",
|
||||
) -> None:
|
||||
"""Record that a seller has been reported to the platform."""
|
||||
...
|
||||
|
||||
def list_reported(self, platform: str = "ebay") -> list[str]:
|
||||
"""Return all platform_seller_ids that have been reported."""
|
||||
...
|
||||
|
||||
def delete_seller_data(self, platform: str, platform_seller_id: str) -> None:
|
||||
"""Permanently erase a seller and all related data (GDPR/eBay compliance)."""
|
||||
...
|
||||
|
||||
def refresh_seller_categories(
|
||||
self,
|
||||
platform: str,
|
||||
seller_ids: list[str],
|
||||
listing_store: Optional[Any] = None,
|
||||
) -> int:
|
||||
"""Derive category_history_json for sellers that lack it from stored listings.
|
||||
|
||||
listing_store: Store holding listings (may differ from self in split-DB mode).
|
||||
Returns count of sellers updated.
|
||||
"""
|
||||
...
|
||||
|
||||
def is_blocklisted(self, platform: str, platform_seller_id: str) -> bool:
|
||||
"""Return True if a seller is on the community scammer blocklist."""
|
||||
...
|
||||
|
||||
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:
|
||||
"""Upsert a seller into the blocklist. Returns the saved entry with id and created_at."""
|
||||
...
|
||||
|
||||
def remove_from_blocklist(self, platform: str, platform_seller_id: str) -> None:
|
||||
"""Remove a seller from the blocklist."""
|
||||
...
|
||||
|
||||
def list_blocklist(self, platform: str = "ebay") -> list[ScammerEntry]:
|
||||
"""Return all blocklisted sellers for a platform, newest first."""
|
||||
...
|
||||
|
||||
def clone(self) -> SharedTableProtocol:
|
||||
"""Create a new independent instance pointing to the same backend."""
|
||||
...
|
||||
198
app/db/store.py
198
app/db/store.py
|
|
@ -8,7 +8,7 @@ from typing import Optional
|
|||
|
||||
from circuitforge_core.db import get_connection, run_migrations
|
||||
|
||||
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore, WatchAlert
|
||||
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
|
|
@ -21,10 +21,6 @@ class Store:
|
|||
# WAL mode: allows concurrent readers + one writer without blocking
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
def clone(self) -> Store:
|
||||
"""Create a new independent instance pointing to the same database."""
|
||||
return Store(self._db_path)
|
||||
|
||||
# --- Seller ---
|
||||
|
||||
def delete_seller_data(self, platform: str, platform_seller_id: str) -> None:
|
||||
|
|
@ -314,66 +310,15 @@ class Store:
|
|||
|
||||
def list_saved_searches(self) -> list[SavedSearch]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT name, query, platform, filters_json, id, created_at, last_run_at, "
|
||||
"monitor_enabled, poll_interval_min, min_trust_score, last_checked_at "
|
||||
"SELECT name, query, platform, filters_json, id, created_at, last_run_at "
|
||||
"FROM saved_searches ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
SavedSearch(
|
||||
name=r[0], query=r[1], platform=r[2], filters_json=r[3],
|
||||
id=r[4], created_at=r[5], last_run_at=r[6],
|
||||
monitor_enabled=bool(r[7]), poll_interval_min=r[8],
|
||||
min_trust_score=r[9], last_checked_at=r[10],
|
||||
)
|
||||
SavedSearch(name=r[0], query=r[1], platform=r[2], filters_json=r[3],
|
||||
id=r[4], created_at=r[5], last_run_at=r[6])
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def update_monitor_settings(
|
||||
self,
|
||||
saved_id: int,
|
||||
*,
|
||||
monitor_enabled: bool,
|
||||
poll_interval_min: int,
|
||||
min_trust_score: int,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"UPDATE saved_searches "
|
||||
"SET monitor_enabled=?, poll_interval_min=?, min_trust_score=? "
|
||||
"WHERE id=?",
|
||||
(int(monitor_enabled), poll_interval_min, min_trust_score, saved_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_monitored_searches(self) -> list[SavedSearch]:
|
||||
"""Return all saved searches with monitoring enabled (used by background poller)."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT name, query, platform, filters_json, id, created_at, last_run_at, "
|
||||
"monitor_enabled, poll_interval_min, min_trust_score, last_checked_at "
|
||||
"FROM saved_searches WHERE monitor_enabled=1"
|
||||
).fetchall()
|
||||
return [
|
||||
SavedSearch(
|
||||
name=r[0], query=r[1], platform=r[2], filters_json=r[3],
|
||||
id=r[4], created_at=r[5], last_run_at=r[6],
|
||||
monitor_enabled=True, poll_interval_min=r[8],
|
||||
min_trust_score=r[9], last_checked_at=r[10],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def mark_search_checked(self, saved_id: int) -> None:
|
||||
self._conn.execute(
|
||||
"UPDATE saved_searches SET last_checked_at=? WHERE id=?",
|
||||
(datetime.now(timezone.utc).isoformat(), saved_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def count_active_monitors(self) -> int:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM saved_searches WHERE monitor_enabled=1"
|
||||
).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def delete_saved_search(self, saved_id: int) -> None:
|
||||
self._conn.execute("DELETE FROM saved_searches WHERE id=?", (saved_id,))
|
||||
self._conn.commit()
|
||||
|
|
@ -385,112 +330,6 @@ class Store:
|
|||
)
|
||||
self._conn.commit()
|
||||
|
||||
# --- WatchAlerts ---
|
||||
|
||||
def upsert_alert(self, alert: WatchAlert) -> tuple[int, bool]:
|
||||
"""Insert alert if not already present. Returns (id, is_new)."""
|
||||
existing = self._conn.execute(
|
||||
"SELECT id FROM watch_alerts WHERE saved_search_id=? AND platform_listing_id=?",
|
||||
(alert.saved_search_id, alert.platform_listing_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing[0], False
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO watch_alerts "
|
||||
"(saved_search_id, platform_listing_id, title, price, currency, trust_score, url) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(alert.saved_search_id, alert.platform_listing_id, alert.title,
|
||||
alert.price, alert.currency, alert.trust_score, alert.url),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cur.lastrowid, True
|
||||
|
||||
def list_alerts(self, *, include_dismissed: bool = False) -> list[WatchAlert]:
|
||||
where = "" if include_dismissed else "WHERE dismissed_at IS NULL"
|
||||
rows = self._conn.execute(
|
||||
f"SELECT id, saved_search_id, platform_listing_id, title, price, currency, "
|
||||
f"trust_score, url, first_alerted_at, dismissed_at "
|
||||
f"FROM watch_alerts {where} ORDER BY first_alerted_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
WatchAlert(
|
||||
id=r[0], saved_search_id=r[1], platform_listing_id=r[2],
|
||||
title=r[3], price=r[4], currency=r[5], trust_score=r[6],
|
||||
url=r[7], first_alerted_at=r[8], dismissed_at=r[9],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def count_undismissed_alerts(self) -> int:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM watch_alerts WHERE dismissed_at IS NULL"
|
||||
).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def dismiss_alert(self, alert_id: int) -> None:
|
||||
self._conn.execute(
|
||||
"UPDATE watch_alerts SET dismissed_at=? WHERE id=?",
|
||||
(datetime.now(timezone.utc).isoformat(), alert_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def dismiss_all_alerts(self) -> int:
|
||||
"""Dismiss all undismissed alerts. Returns count dismissed."""
|
||||
cur = self._conn.execute(
|
||||
"UPDATE watch_alerts SET dismissed_at=? WHERE dismissed_at IS NULL",
|
||||
(datetime.now(timezone.utc).isoformat(),),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
# --- ActiveMonitors (sched_db / shared_db) ---
|
||||
|
||||
def upsert_active_monitor(
|
||||
self,
|
||||
user_db_path: str,
|
||||
saved_search_id: int,
|
||||
poll_interval_min: int,
|
||||
) -> None:
|
||||
"""Register or update a monitor in the cross-user registry (sched_db)."""
|
||||
self._conn.execute(
|
||||
"INSERT INTO active_monitors (user_db_path, saved_search_id, poll_interval_min) "
|
||||
"VALUES (?,?,?) "
|
||||
"ON CONFLICT(user_db_path, saved_search_id) DO UPDATE SET "
|
||||
" poll_interval_min=excluded.poll_interval_min",
|
||||
(user_db_path, saved_search_id, poll_interval_min),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def remove_active_monitor(self, user_db_path: str, saved_search_id: int) -> None:
|
||||
self._conn.execute(
|
||||
"DELETE FROM active_monitors WHERE user_db_path=? AND saved_search_id=?",
|
||||
(user_db_path, saved_search_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_due_active_monitors(self) -> list[tuple[str, int, int]]:
|
||||
"""Return (user_db_path, saved_search_id, poll_interval_min) for monitors that are due.
|
||||
|
||||
Due = never checked OR last_checked_at is old enough given poll_interval_min.
|
||||
Uses SQLite's strftime('%s') for epoch arithmetic without Python datetime overhead.
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT user_db_path, saved_search_id, poll_interval_min "
|
||||
"FROM active_monitors "
|
||||
"WHERE last_checked_at IS NULL "
|
||||
" OR (strftime('%s','now') - strftime('%s', last_checked_at)) "
|
||||
" >= poll_interval_min * 60"
|
||||
).fetchall()
|
||||
return [(r[0], r[1], r[2]) for r in rows]
|
||||
|
||||
def mark_active_monitor_checked(self, user_db_path: str, saved_search_id: int) -> None:
|
||||
self._conn.execute(
|
||||
"UPDATE active_monitors SET last_checked_at=? "
|
||||
"WHERE user_db_path=? AND saved_search_id=?",
|
||||
(datetime.now(timezone.utc).isoformat(), user_db_path, saved_search_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# --- ScammerBlocklist ---
|
||||
|
||||
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:
|
||||
|
|
@ -543,35 +382,6 @@ class Store:
|
|||
for r in rows
|
||||
]
|
||||
|
||||
# --- Reported Sellers ---
|
||||
|
||||
def mark_reported(
|
||||
self,
|
||||
platform: str,
|
||||
platform_seller_id: str,
|
||||
username: Optional[str] = None,
|
||||
reported_by: str = "user",
|
||||
) -> None:
|
||||
"""Record that the user has filed an eBay T&S report for this seller.
|
||||
|
||||
Uses IGNORE on conflict so the first-report timestamp is preserved.
|
||||
"""
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO reported_sellers "
|
||||
"(platform, platform_seller_id, username, reported_by) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(platform, platform_seller_id, username, reported_by),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_reported(self, platform: str = "ebay") -> list[str]:
|
||||
"""Return all platform_seller_ids that have been reported."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT platform_seller_id FROM reported_sellers WHERE platform=?",
|
||||
(platform,),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def save_community_signal(self, seller_id: str, confirmed: bool) -> None:
|
||||
"""Record a user's trust-score feedback signal into the shared DB."""
|
||||
self._conn.execute(
|
||||
|
|
|
|||
|
|
@ -2,15 +2,9 @@
|
|||
# BSL 1.1 License
|
||||
"""LLM query builder — translates natural language to eBay SearchFilters.
|
||||
|
||||
Supports two backends, selected at construction time:
|
||||
|
||||
cforch_url — cf-orch task endpoint (cloud/premium). The coordinator resolves
|
||||
product+task to a model and returns an allocation. The caller
|
||||
POSTs to the allocated service URL, then DELETEs the allocation.
|
||||
|
||||
llm_router — circuitforge_core.LLMRouter (local installs: ollama/vllm/api keys).
|
||||
|
||||
Exactly one of cforch_url or llm_router must be supplied.
|
||||
The QueryTranslator calls LLMRouter.complete() (synchronous) with a domain-aware
|
||||
system prompt. The prompt includes category hints injected from EbayCategoryCache.
|
||||
The LLM returns a single JSON object matching SearchParamsResponse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -19,8 +13,6 @@ import logging
|
|||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.platforms.ebay.categories import EbayCategoryCache
|
||||
|
||||
|
|
@ -136,23 +128,11 @@ class QueryTranslator:
|
|||
|
||||
Args:
|
||||
category_cache: An EbayCategoryCache instance (may have empty cache).
|
||||
cforch_url: cf-orch coordinator base URL (cloud/premium path).
|
||||
llm_router: A circuitforge_core LLMRouter instance (local path).
|
||||
|
||||
Exactly one of cforch_url or llm_router must be provided.
|
||||
llm_router: An LLMRouter instance from circuitforge_core.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
category_cache: "EbayCategoryCache",
|
||||
*,
|
||||
cforch_url: str | None = None,
|
||||
llm_router: object | None = None,
|
||||
) -> None:
|
||||
if cforch_url is None and llm_router is None:
|
||||
raise ValueError("Either cforch_url or llm_router must be provided")
|
||||
def __init__(self, category_cache: "EbayCategoryCache", llm_router: object) -> None:
|
||||
self._cache = category_cache
|
||||
self._cforch_url = cforch_url
|
||||
self._llm_router = llm_router
|
||||
|
||||
def translate(self, natural_language: str) -> SearchParamsResponse:
|
||||
|
|
@ -174,58 +154,14 @@ class QueryTranslator:
|
|||
system_prompt = _SYSTEM_PROMPT_TEMPLATE.format(category_hints=category_hints)
|
||||
|
||||
try:
|
||||
if self._cforch_url:
|
||||
raw = self._call_orch(system_prompt, natural_language)
|
||||
else:
|
||||
raw = self._call_local(system_prompt, natural_language)
|
||||
except QueryTranslatorError:
|
||||
raise
|
||||
raw = self._llm_router.complete(
|
||||
natural_language,
|
||||
system=system_prompt,
|
||||
max_tokens=512,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise QueryTranslatorError(
|
||||
f"LLM backend error: {exc}", raw=""
|
||||
) from exc
|
||||
|
||||
return _parse_response(raw)
|
||||
|
||||
def _call_orch(self, system_prompt: str, user_message: str) -> str:
|
||||
"""Allocate via cf-orch task endpoint, call the model, release the slot."""
|
||||
alloc_resp = httpx.post(
|
||||
f"{self._cforch_url}/api/inference/task",
|
||||
json={"product": "snipe", "task": "query_translation"},
|
||||
timeout=10.0,
|
||||
)
|
||||
alloc_resp.raise_for_status()
|
||||
alloc = alloc_resp.json()
|
||||
service_url = alloc["url"]
|
||||
allocation_id = alloc["allocation_id"]
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{service_url}/v1/chat/completions",
|
||||
json={
|
||||
"model": "__auto__",
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"max_tokens": 512,
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
finally:
|
||||
try:
|
||||
httpx.delete(
|
||||
f"{self._cforch_url}/api/services/cf-text/allocations/{allocation_id}",
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to release cf-orch allocation %s", allocation_id)
|
||||
|
||||
def _call_local(self, system_prompt: str, user_message: str) -> str:
|
||||
"""Call the locally-configured LLMRouter (ollama/vllm/api keys)."""
|
||||
return self._llm_router.complete( # type: ignore[union-attr]
|
||||
user_message,
|
||||
system=system_prompt,
|
||||
max_tokens=512,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Snipe LLMRouter shim — tri-level config path priority.
|
|||
Config lookup order:
|
||||
1. <repo>/config/llm.yaml — per-install local override
|
||||
2. ~/.config/circuitforge/llm.yaml — user-level config (circuitforge-core default)
|
||||
3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST, GPU_SERVER_URL)
|
||||
3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST, CF_ORCH_URL)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ from typing import Optional
|
|||
|
||||
from app.db.models import Listing, Seller
|
||||
|
||||
# Single source of truth for platform validation.
|
||||
# Phase 2 will extend this set as new adapters are implemented.
|
||||
SUPPORTED_PLATFORMS: frozenset[str] = frozenset({"ebay", "mercari"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchFilters:
|
||||
|
|
@ -22,8 +18,6 @@ class SearchFilters:
|
|||
must_include: list[str] = field(default_factory=list) # client-side title filter
|
||||
must_exclude: list[str] = field(default_factory=list) # forwarded to eBay -term AND client-side
|
||||
category_id: Optional[str] = None # eBay category ID (e.g. "27386" = GPUs)
|
||||
must_include_mode: str = "all" # "all" | "any" | "groups"
|
||||
adapter: str = "auto" # "auto" | "api" | "scraper"
|
||||
|
||||
|
||||
class PlatformAdapter(ABC):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"""eBay Browse + Trading API adapter."""
|
||||
"""eBay Browse API adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
|
@ -22,7 +21,7 @@ _SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
|
|||
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
|
||||
|
||||
from app.db.models import Listing, MarketComp, Seller
|
||||
from app.db.protocol import SharedTableProtocol
|
||||
from app.db.store import Store
|
||||
from app.platforms import PlatformAdapter, SearchFilters
|
||||
from app.platforms.ebay.auth import EbayTokenManager
|
||||
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
||||
|
|
@ -67,7 +66,7 @@ BROWSE_BASE = {
|
|||
|
||||
|
||||
class EbayAdapter(PlatformAdapter):
|
||||
def __init__(self, token_manager: EbayTokenManager, shared_store: SharedTableProtocol, env: str = "production"):
|
||||
def __init__(self, token_manager: EbayTokenManager, shared_store: Store, env: str = "production"):
|
||||
self._tokens = token_manager
|
||||
self._store = shared_store
|
||||
self._env = env
|
||||
|
|
@ -211,70 +210,6 @@ class EbayAdapter(PlatformAdapter):
|
|||
except Exception as e:
|
||||
log.debug("Shopping API enrich failed for %s: %s", username, e)
|
||||
|
||||
# ── Trading API GetUser (requires user OAuth token) ───────────────────────
|
||||
|
||||
_TRADING_API_URL = "https://api.ebay.com/ws/api.dll"
|
||||
_TRADING_API_COMPATIBILITY = "1283"
|
||||
|
||||
def enrich_seller_trading_api(self, username: str, user_access_token: str) -> bool:
|
||||
"""Enrich a seller's account_age_days using Trading API GetUser.
|
||||
|
||||
Uses the connected user's OAuth access token (Authorization Code flow),
|
||||
which bypasses Shopping API rate limits and works even when the Shopping
|
||||
API GetUserProfile call is throttled.
|
||||
|
||||
Unlike BTF scraping, this is a clean API call (~200ms, no Playwright).
|
||||
Called from the search endpoint when the requesting user has connected
|
||||
their eBay account.
|
||||
|
||||
Returns True if enrichment succeeded, False on any failure.
|
||||
"""
|
||||
xml_body = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<GetUserRequest xmlns="urn:ebay:apis:eBLBaseComponents">'
|
||||
f'<UserID>{username}</UserID>'
|
||||
'</GetUserRequest>'
|
||||
)
|
||||
try:
|
||||
resp = requests.post(
|
||||
self._TRADING_API_URL,
|
||||
headers={
|
||||
"X-EBAY-API-CALL-NAME": "GetUser",
|
||||
"X-EBAY-API-SITEID": "0",
|
||||
"X-EBAY-API-COMPATIBILITY-LEVEL": self._TRADING_API_COMPATIBILITY,
|
||||
"X-EBAY-API-IAF-TOKEN": f"Bearer {user_access_token}",
|
||||
"Content-Type": "text/xml",
|
||||
},
|
||||
data=xml_body.encode("utf-8"),
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
root = ET.fromstring(resp.text)
|
||||
ns = {"e": "urn:ebay:apis:eBLBaseComponents"}
|
||||
|
||||
ack = root.findtext("e:Ack", namespaces=ns)
|
||||
if ack not in ("Success", "Warning"):
|
||||
errors = [e.findtext("e:LongMessage", namespaces=ns, default="")
|
||||
for e in root.findall("e:Errors", namespaces=ns)]
|
||||
log.debug("Trading API GetUser failed for %s: %s", username, errors)
|
||||
return False
|
||||
|
||||
reg_date = root.findtext("e:User/e:RegistrationDate", namespaces=ns)
|
||||
if not reg_date:
|
||||
return False
|
||||
|
||||
dt = datetime.fromisoformat(reg_date.replace("Z", "+00:00"))
|
||||
age_days = (datetime.now(timezone.utc) - dt).days
|
||||
seller = self._store.get_seller("ebay", username)
|
||||
if seller:
|
||||
self._store.save_seller(replace(seller, account_age_days=age_days))
|
||||
log.debug("Trading API GetUser: %s registered %d days ago", username, age_days)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
log.debug("Trading API GetUser failed for %s: %s", username, exc)
|
||||
return False
|
||||
|
||||
def get_seller(self, seller_platform_id: str) -> Optional[Seller]:
|
||||
cached = self._store.get_seller("ebay", seller_platform_id)
|
||||
if cached:
|
||||
|
|
|
|||
|
|
@ -1,400 +0,0 @@
|
|||
"""Thread-local Playwright browser manager for the eBay scraper.
|
||||
|
||||
Each uvicorn worker thread that calls fetch_html() gets its own Playwright
|
||||
instance, browser, and context — created lazily on first use. This avoids
|
||||
the "cannot switch to a different thread" error that arises when Playwright
|
||||
sync API instances are shared across threads (they bind their greenlet event
|
||||
loop to the creating thread).
|
||||
|
||||
Key design:
|
||||
- Thread-local: _thread_local.slot holds the _PooledBrowser for the current
|
||||
thread. No slot is ever handed to another thread.
|
||||
- Lazy creation: slots are created on first fetch_html() call per thread, not
|
||||
at startup. start() is a lightweight lifecycle marker only.
|
||||
- Registry: _slot_registry (keyed by thread-id) lets stop() close every active
|
||||
slot across all threads without walking thread-local storage.
|
||||
- Replenishment: after each use the dirty context is closed and a fresh one
|
||||
opened on the same browser. Browser launch overhead is paid at most once
|
||||
per worker thread lifetime.
|
||||
- Graceful degradation: if Playwright / Xvfb is unavailable, fetch_html falls
|
||||
back to _fetch_fresh (identical behavior to before this module existed).
|
||||
|
||||
Pool size is read from BROWSER_POOL_SIZE env var (default: 2) but is now a
|
||||
soft limit — used only for documentation; actual concurrency is bounded by
|
||||
uvicorn's thread count.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_pool_display_counter = itertools.cycle(range(200, 400))
|
||||
|
||||
_CHROMIUM_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
_XVFB_ARGS = ["-screen", "0", "1280x800x24", "-ac"]
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
_VIEWPORT = {"width": 1280, "height": 800}
|
||||
|
||||
# Thread-local storage: each thread gets its own _PooledBrowser slot.
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PooledBrowser:
|
||||
"""One browser slot, bound to a single thread."""
|
||||
xvfb: subprocess.Popen
|
||||
pw: object # playwright instance (sync_playwright().__enter__())
|
||||
browser: object # playwright Browser
|
||||
ctx: object # playwright BrowserContext (fresh per use)
|
||||
display_num: int
|
||||
last_used_ts: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
def _launch_slot() -> _PooledBrowser:
|
||||
"""Launch a new Xvfb display + headed Chromium browser + fresh context.
|
||||
|
||||
Must be called from the thread that will use the slot.
|
||||
"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
from playwright_stealth import Stealth # noqa: F401
|
||||
|
||||
display_num = next(_pool_display_counter)
|
||||
display = f":{display_num}"
|
||||
env = os.environ.copy()
|
||||
env["DISPLAY"] = display
|
||||
|
||||
xvfb = subprocess.Popen(
|
||||
["Xvfb", display] + _XVFB_ARGS,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
time.sleep(0.3)
|
||||
|
||||
pw = sync_playwright().start()
|
||||
try:
|
||||
browser = pw.chromium.launch(
|
||||
headless=False,
|
||||
env=env,
|
||||
args=_CHROMIUM_ARGS,
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent=_USER_AGENT,
|
||||
viewport=_VIEWPORT,
|
||||
)
|
||||
except Exception:
|
||||
pw.stop()
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
raise
|
||||
|
||||
return _PooledBrowser(
|
||||
xvfb=xvfb,
|
||||
pw=pw,
|
||||
browser=browser,
|
||||
ctx=ctx,
|
||||
display_num=display_num,
|
||||
last_used_ts=time.time(),
|
||||
)
|
||||
|
||||
|
||||
def _close_slot(slot: _PooledBrowser) -> None:
|
||||
"""Cleanly close a slot: context → browser → Playwright → Xvfb."""
|
||||
try:
|
||||
slot.ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
slot.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
slot.pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
slot.xvfb.terminate()
|
||||
slot.xvfb.wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _replenish_slot(slot: _PooledBrowser) -> _PooledBrowser:
|
||||
"""Close the used context and open a fresh one on the same browser."""
|
||||
try:
|
||||
slot.ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_ctx = slot.browser.new_context(
|
||||
user_agent=_USER_AGENT,
|
||||
viewport=_VIEWPORT,
|
||||
)
|
||||
return _PooledBrowser(
|
||||
xvfb=slot.xvfb,
|
||||
pw=slot.pw,
|
||||
browser=slot.browser,
|
||||
ctx=new_ctx,
|
||||
display_num=slot.display_num,
|
||||
last_used_ts=time.time(),
|
||||
)
|
||||
|
||||
|
||||
class BrowserPool:
|
||||
"""Thread-local Playwright browser manager.
|
||||
|
||||
Each thread that calls fetch_html() owns its own browser instance.
|
||||
No slots are shared between threads.
|
||||
"""
|
||||
|
||||
def __init__(self, size: int = 2) -> None:
|
||||
self._size = size
|
||||
self._lock = threading.Lock()
|
||||
self._started = False
|
||||
self._stopped = False
|
||||
self._playwright_available: Optional[bool] = None
|
||||
# Registry of all active slots keyed by thread id — used only by stop().
|
||||
self._slot_registry: dict[int, _PooledBrowser] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Mark the pool as started. Slots are created lazily per thread."""
|
||||
with self._lock:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
|
||||
if not self._check_playwright():
|
||||
log.warning(
|
||||
"BrowserPool: Playwright / Xvfb not available — "
|
||||
"pool disabled, falling back to per-call fresh browser."
|
||||
)
|
||||
return
|
||||
|
||||
log.info("BrowserPool: started (thread-local mode, size hint=%d)", self._size)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Close all active slots across all threads."""
|
||||
with self._lock:
|
||||
self._stopped = True
|
||||
registry_snapshot = dict(self._slot_registry)
|
||||
|
||||
closed = 0
|
||||
for slot in registry_snapshot.values():
|
||||
_close_slot(slot)
|
||||
closed += 1
|
||||
self._slot_registry.clear()
|
||||
log.info("BrowserPool: stopped, closed %d slot(s)", closed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core fetch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def fetch_html(
|
||||
self,
|
||||
url: str,
|
||||
delay: float = 1.0,
|
||||
wait_for_selector: Optional[str] = None,
|
||||
wait_for_timeout_ms: int = 2000,
|
||||
) -> str:
|
||||
"""Navigate to *url* and return the rendered HTML.
|
||||
|
||||
Uses the calling thread's browser slot (creates one if needed).
|
||||
Falls back to a fresh browser if Playwright is unavailable or the
|
||||
slot fails.
|
||||
"""
|
||||
time.sleep(delay)
|
||||
|
||||
slot = self._get_or_create_thread_slot()
|
||||
|
||||
if slot is not None:
|
||||
try:
|
||||
html = self._fetch_with_slot(
|
||||
slot, url,
|
||||
wait_for_selector=wait_for_selector,
|
||||
wait_for_timeout_ms=wait_for_timeout_ms,
|
||||
)
|
||||
try:
|
||||
fresh_slot = _replenish_slot(slot)
|
||||
self._register_slot(fresh_slot)
|
||||
except Exception as exc:
|
||||
log.warning("BrowserPool: replenish failed, slot discarded: %s", exc)
|
||||
_close_slot(slot)
|
||||
self._unregister_slot()
|
||||
return html
|
||||
except Exception as exc:
|
||||
log.warning("BrowserPool: pooled fetch failed (%s) — closing slot", exc)
|
||||
_close_slot(slot)
|
||||
self._unregister_slot()
|
||||
|
||||
return self._fetch_fresh(
|
||||
url,
|
||||
wait_for_selector=wait_for_selector,
|
||||
wait_for_timeout_ms=wait_for_timeout_ms,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread-local slot management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_or_create_thread_slot(self) -> Optional[_PooledBrowser]:
|
||||
"""Return the calling thread's slot, creating it if absent."""
|
||||
if not self._check_playwright():
|
||||
return None
|
||||
|
||||
slot: Optional[_PooledBrowser] = getattr(_thread_local, "slot", None)
|
||||
if slot is not None:
|
||||
return slot
|
||||
|
||||
try:
|
||||
slot = _launch_slot()
|
||||
self._register_slot(slot)
|
||||
log.debug("BrowserPool: launched slot :%d for thread %d",
|
||||
slot.display_num, threading.get_ident())
|
||||
return slot
|
||||
except Exception as exc:
|
||||
log.warning("BrowserPool: slot launch failed: %s", exc)
|
||||
return None
|
||||
|
||||
def _register_slot(self, slot: _PooledBrowser) -> None:
|
||||
"""Bind slot to the calling thread (both thread-local and registry)."""
|
||||
_thread_local.slot = slot
|
||||
with self._lock:
|
||||
self._slot_registry[threading.get_ident()] = slot
|
||||
|
||||
def _unregister_slot(self) -> None:
|
||||
"""Remove the calling thread's slot from thread-local and registry."""
|
||||
_thread_local.slot = None
|
||||
with self._lock:
|
||||
self._slot_registry.pop(threading.get_ident(), None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_playwright(self) -> bool:
|
||||
if self._playwright_available is not None:
|
||||
return self._playwright_available
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
from playwright_stealth import Stealth # noqa: F401
|
||||
self._playwright_available = True
|
||||
except ImportError:
|
||||
self._playwright_available = False
|
||||
return self._playwright_available
|
||||
|
||||
def _fetch_with_slot(
|
||||
self,
|
||||
slot: _PooledBrowser,
|
||||
url: str,
|
||||
wait_for_selector: Optional[str] = None,
|
||||
wait_for_timeout_ms: int = 2000,
|
||||
) -> str:
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
page = slot.ctx.new_page()
|
||||
try:
|
||||
Stealth().apply_stealth_sync(page)
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||
if wait_for_selector:
|
||||
try:
|
||||
page.wait_for_selector(wait_for_selector, timeout=15_000)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
page.wait_for_timeout(wait_for_timeout_ms)
|
||||
return page.content()
|
||||
finally:
|
||||
try:
|
||||
page.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _fetch_fresh(
|
||||
self,
|
||||
url: str,
|
||||
wait_for_selector: Optional[str] = None,
|
||||
wait_for_timeout_ms: int = 2000,
|
||||
) -> str:
|
||||
import subprocess as _subprocess
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
from playwright_stealth import Stealth
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Playwright not installed — cannot fetch pages. "
|
||||
"Install playwright and playwright-stealth in the Docker image."
|
||||
) from exc
|
||||
|
||||
display_num = next(_pool_display_counter)
|
||||
display = f":{display_num}"
|
||||
env = os.environ.copy()
|
||||
env["DISPLAY"] = display
|
||||
|
||||
xvfb = _subprocess.Popen(
|
||||
["Xvfb", display] + _XVFB_ARGS,
|
||||
stdout=_subprocess.DEVNULL,
|
||||
stderr=_subprocess.DEVNULL,
|
||||
)
|
||||
time.sleep(0.3)
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(
|
||||
headless=False,
|
||||
env=env,
|
||||
args=_CHROMIUM_ARGS,
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent=_USER_AGENT,
|
||||
viewport=_VIEWPORT,
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||
if wait_for_selector:
|
||||
try:
|
||||
page.wait_for_selector(wait_for_selector, timeout=15_000)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
page.wait_for_timeout(wait_for_timeout_ms)
|
||||
html = page.content()
|
||||
browser.close()
|
||||
finally:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
|
||||
return html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_pool: Optional[BrowserPool] = None
|
||||
_pool_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_pool() -> BrowserPool:
|
||||
"""Return the module-level BrowserPool singleton (creates it if needed)."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
with _pool_lock:
|
||||
if _pool is None:
|
||||
size = int(os.environ.get("BROWSER_POOL_SIZE", "2"))
|
||||
_pool = BrowserPool(size)
|
||||
return _pool
|
||||
|
|
@ -25,7 +25,7 @@ log = logging.getLogger(__name__)
|
|||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.db.models import Listing, MarketComp, Seller
|
||||
from app.db.protocol import SharedTableProtocol
|
||||
from app.db.store import Store
|
||||
from app.platforms import PlatformAdapter, SearchFilters
|
||||
|
||||
EBAY_SEARCH_URL = "https://www.ebay.com/sch/i.html"
|
||||
|
|
@ -286,12 +286,12 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
|||
category_history) cause TrustScorer to set score_is_partial=True.
|
||||
"""
|
||||
|
||||
def __init__(self, shared_store: SharedTableProtocol, delay: float = 1.0):
|
||||
def __init__(self, shared_store: Store, delay: float = 1.0):
|
||||
self._store = shared_store
|
||||
self._delay = delay
|
||||
|
||||
def _fetch_url(self, url: str) -> str:
|
||||
"""Core Playwright fetch — stealthed headed Chromium via pre-warmed browser pool.
|
||||
"""Core Playwright fetch — stealthed headed Chromium via Xvfb.
|
||||
|
||||
Shared by both search (_get) and BTF item-page enrichment (_fetch_item_html).
|
||||
Results cached for _HTML_CACHE_TTL seconds.
|
||||
|
|
@ -300,8 +300,44 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
|||
if cached and time.time() < cached[1]:
|
||||
return cached[0]
|
||||
|
||||
from app.platforms.ebay.browser_pool import get_pool # noqa: PLC0415 — lazy import
|
||||
html = get_pool().fetch_html(url, delay=self._delay)
|
||||
time.sleep(self._delay)
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
display_num = next(_display_counter)
|
||||
display = f":{display_num}"
|
||||
xvfb = subprocess.Popen(
|
||||
["Xvfb", display, "-screen", "0", "1280x800x24"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["DISPLAY"] = display
|
||||
|
||||
try:
|
||||
from playwright.sync_api import (
|
||||
sync_playwright, # noqa: PLC0415 — lazy: only needed in Docker
|
||||
)
|
||||
from playwright_stealth import Stealth # noqa: PLC0415
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(
|
||||
headless=False,
|
||||
env=env,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent=_HEADERS["User-Agent"],
|
||||
viewport={"width": 1280, "height": 800},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_timeout(2000) # let any JS challenges resolve
|
||||
html = page.content()
|
||||
browser.close()
|
||||
finally:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
|
||||
_html_cache[url] = (html, time.time() + _HTML_CACHE_TTL)
|
||||
return html
|
||||
|
|
@ -374,6 +410,8 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
|||
Does not raise — failures per-seller are silently skipped so the main
|
||||
search response is never blocked.
|
||||
"""
|
||||
db_path = self._store._db_path # capture for thread-local Store creation
|
||||
|
||||
def _enrich_one(item: tuple[str, str]) -> None:
|
||||
seller_id, listing_id = item
|
||||
try:
|
||||
|
|
@ -386,7 +424,7 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
|||
)
|
||||
if age_days is None and fb_count is None:
|
||||
return # nothing new to write
|
||||
thread_store = self._store.clone()
|
||||
thread_store = Store(db_path)
|
||||
seller = thread_store.get_seller("ebay", seller_id)
|
||||
if not seller:
|
||||
log.warning("BTF enrich: seller %s not found in DB", seller_id)
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
"""Mercari platform adapter."""
|
||||
from app.platforms.mercari.adapter import MercariAdapter
|
||||
|
||||
__all__ = ["MercariAdapter"]
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
"""MercariAdapter — scraper-based Mercari platform adapter.
|
||||
|
||||
Trust signal coverage vs eBay:
|
||||
✅ feedback_count (NumSales from listing page)
|
||||
✅ feedback_ratio (ReviewStarsWrapper data-stars / 5)
|
||||
❌ account_age_days (requires seller profile page — future work)
|
||||
❌ category_history (not exposed in HTML — future work)
|
||||
✅ price_vs_market (computed by trust scorer from comps, same as eBay)
|
||||
|
||||
Because account_age and category_history are always None, TrustScore.score_is_partial
|
||||
will be True for all Mercari results. The aggregator handles this correctly
|
||||
by scoring only from available signals.
|
||||
|
||||
seller_platform_id on Listing objects holds the product_id (e.g. "m86032668393")
|
||||
rather than the seller username, because search results don't expose seller identity.
|
||||
get_seller() resolves the product_id → seller by fetching the listing page.
|
||||
The DB lookup key is (platform="mercari", platform_seller_id=product_id).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from app.db.models import Listing, MarketComp, Seller
|
||||
from app.db.store import Store
|
||||
from app.platforms import PlatformAdapter, SearchFilters
|
||||
from app.platforms.mercari.scraper import (
|
||||
build_search_url,
|
||||
parse_listing_html,
|
||||
parse_search_html,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SELLER_CACHE_TTL_HOURS = 6
|
||||
_BETWEEN_LISTING_FETCH_SECS = 1.5
|
||||
|
||||
|
||||
class MercariAdapter(PlatformAdapter):
|
||||
def __init__(self, store: Store) -> None:
|
||||
self._store = store
|
||||
|
||||
def search(self, query: str, filters: SearchFilters) -> list[Listing]:
|
||||
from app.platforms.ebay.browser_pool import get_pool
|
||||
|
||||
url = build_search_url(query, filters.max_price, filters.min_price)
|
||||
log.info("mercari: fetching search URL: %s", url)
|
||||
|
||||
html = get_pool().fetch_html(
|
||||
url,
|
||||
delay=1.0,
|
||||
wait_for_timeout_ms=8000,
|
||||
)
|
||||
raw_listings = parse_search_html(html)
|
||||
|
||||
listings: list[Listing] = []
|
||||
seen: set[str] = set()
|
||||
for raw in raw_listings:
|
||||
pid = raw["product_id"]
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
listings.append(_normalise_listing(raw, query))
|
||||
|
||||
log.info("mercari: parsed %d listings for %r", len(listings), query)
|
||||
|
||||
# Client-side keyword filter (mirrors eBay scraper behaviour).
|
||||
if filters.must_include:
|
||||
listings = _apply_keyword_filter(listings, filters.must_include, filters.must_include_mode)
|
||||
if filters.must_exclude:
|
||||
listings = _apply_exclude_filter(listings, filters.must_exclude)
|
||||
|
||||
return listings
|
||||
|
||||
def get_seller(self, seller_platform_id: str) -> Optional[Seller]:
|
||||
"""Fetch seller data from the listing page identified by seller_platform_id.
|
||||
|
||||
For Mercari, seller_platform_id is the product_id (e.g. "m86032668393")
|
||||
because seller usernames aren't available from search results HTML.
|
||||
"""
|
||||
cached = self._store.get_seller("mercari", seller_platform_id)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
from app.platforms.ebay.browser_pool import get_pool
|
||||
|
||||
url = f"https://www.mercari.com/us/item/{seller_platform_id}/"
|
||||
try:
|
||||
time.sleep(_BETWEEN_LISTING_FETCH_SECS)
|
||||
html = get_pool().fetch_html(
|
||||
url,
|
||||
delay=0.5,
|
||||
wait_for_timeout_ms=6000,
|
||||
)
|
||||
raw = parse_listing_html(html, seller_platform_id)
|
||||
seller = _normalise_seller(raw)
|
||||
self._store.save_seller(seller)
|
||||
return seller
|
||||
except Exception as exc:
|
||||
log.warning("mercari: get_seller failed for %s: %s", seller_platform_id, exc)
|
||||
return None
|
||||
|
||||
def get_completed_sales(self, query: str, pages: int = 1) -> list[Listing]:
|
||||
"""Mercari sold-listing comps — stubbed for Phase 3.
|
||||
|
||||
Mercari exposes sold listings via ?status=ITEM_STATUS_TRADING but the
|
||||
data is sparse. Phase 3 will implement comp extraction here; for now
|
||||
the trust scorer falls back to price_vs_market=None (partial score).
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normalisation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalise_listing(raw: dict, query: str) -> Listing:
|
||||
return Listing(
|
||||
platform="mercari",
|
||||
platform_listing_id=raw["product_id"],
|
||||
title=raw["title"],
|
||||
price=raw["price"],
|
||||
currency="USD",
|
||||
condition="", # not available from search results; get_seller() populates this
|
||||
seller_platform_id=raw["product_id"], # see module docstring
|
||||
url=raw["url"],
|
||||
photo_urls=[raw["photo_url"]] if raw.get("photo_url") else [],
|
||||
listing_age_days=0,
|
||||
buying_format="fixed_price",
|
||||
category_name=None,
|
||||
)
|
||||
|
||||
|
||||
def _normalise_seller(raw: dict) -> Seller:
|
||||
stars = raw.get("stars", 0.0)
|
||||
feedback_ratio = min(stars / 5.0, 1.0) if stars > 0 else 0.0
|
||||
|
||||
return Seller(
|
||||
platform="mercari",
|
||||
platform_seller_id=raw["product_id"],
|
||||
username=raw.get("username", ""),
|
||||
account_age_days=None, # not available without seller profile page
|
||||
feedback_count=raw.get("num_sales", 0),
|
||||
feedback_ratio=feedback_ratio,
|
||||
category_history_json=json.dumps({}),
|
||||
)
|
||||
|
||||
|
||||
def _apply_keyword_filter(listings: list[Listing], must_include: list[str], mode: str) -> list[Listing]:
|
||||
if not must_include:
|
||||
return listings
|
||||
|
||||
def _matches(listing: Listing) -> bool:
|
||||
title = listing.title.lower()
|
||||
if mode == "any":
|
||||
return any(kw.lower() in title for kw in must_include)
|
||||
# "all" (default) and "groups" both require all terms present
|
||||
return all(kw.lower() in title for kw in must_include)
|
||||
|
||||
return [l for l in listings if _matches(l)]
|
||||
|
||||
|
||||
def _apply_exclude_filter(listings: list[Listing], must_exclude: list[str]) -> list[Listing]:
|
||||
if not must_exclude:
|
||||
return listings
|
||||
|
||||
def _clean(listing: Listing) -> bool:
|
||||
title = listing.title.lower()
|
||||
return not any(term.lower() in title for term in must_exclude)
|
||||
|
||||
return [l for l in listings if _clean(l)]
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
"""Mercari search + listing page scraper.
|
||||
|
||||
Uses the shared eBay browser pool (headed Chromium + Xvfb + playwright-stealth)
|
||||
which already bypasses Cloudflare Turnstile. Import the pool singleton from
|
||||
ebay.browser_pool so both platforms share the same warm Chromium instances.
|
||||
|
||||
Seller data is NOT available from search results HTML — only from individual
|
||||
listing pages. The adapter lazily fetches listing pages in get_seller().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://www.mercari.com"
|
||||
_SEARCH_PATH = "/search/"
|
||||
_ITEM_PATH = "/us/item/"
|
||||
|
||||
_PRICE_RE = re.compile(r"[\d,]+\.?\d*")
|
||||
_POSTED_RE = re.compile(r"(\d{2})/(\d{2})/(\d{2,4})") # MM/DD/YY or MM/DD/YYYY
|
||||
|
||||
|
||||
def build_search_url(query: str, max_price: Optional[float] = None, min_price: Optional[float] = None) -> str:
|
||||
# No explicit sortBy — Mercari's default (relevance) is the most useful order.
|
||||
# "sortBy=SORT_SCORE" was a deprecated value that returns an empty results page.
|
||||
params: dict = {"keyword": query}
|
||||
# Mercari accepts priceMin/priceMax as whole dollar strings (not cents)
|
||||
if min_price is not None and min_price > 0:
|
||||
params["priceMin"] = str(int(min_price))
|
||||
if max_price is not None and max_price > 0:
|
||||
params["priceMax"] = str(int(max_price))
|
||||
return f"{_BASE}{_SEARCH_PATH}?{urlencode(params)}"
|
||||
|
||||
|
||||
def parse_search_html(html: str) -> list[dict]:
|
||||
"""Parse Mercari search results HTML into a list of raw listing dicts."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
results: list[dict] = []
|
||||
|
||||
for item in soup.find_all(attrs={"data-testid": "ItemContainer"}):
|
||||
pid = item.get("data-productid", "")
|
||||
if not pid:
|
||||
continue
|
||||
|
||||
parent = item.parent
|
||||
href = parent.get("href") if parent and parent.name == "a" else None
|
||||
url = f"{_BASE}{href}" if href else f"{_BASE}{_ITEM_PATH}{pid}/"
|
||||
|
||||
name_el = item.find(attrs={"data-testid": "ItemName"})
|
||||
title = name_el.get_text(strip=True) if name_el else ""
|
||||
|
||||
price = _extract_current_price(item)
|
||||
img_el = item.find("img")
|
||||
photo_url = img_el.get("src", "") if img_el else ""
|
||||
|
||||
results.append({
|
||||
"product_id": pid,
|
||||
"url": url,
|
||||
"title": title,
|
||||
"price": price,
|
||||
"photo_url": photo_url,
|
||||
"brand": item.get("data-brand", ""),
|
||||
"is_on_sale": item.get("data-is-on-sale") == "true",
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _extract_current_price(item: BeautifulSoup) -> float:
|
||||
"""Return the current (non-strikethrough) price from an ItemContainer."""
|
||||
price_el = item.find(attrs={"data-testid": "ProductThumbItemPrice"})
|
||||
if not price_el:
|
||||
return 0.0
|
||||
|
||||
# Direct text nodes are the current price; the nested span is the original.
|
||||
price_text = "".join(
|
||||
str(c) for c in price_el.children if isinstance(c, NavigableString)
|
||||
).strip()
|
||||
|
||||
m = _PRICE_RE.search(price_text)
|
||||
if m:
|
||||
try:
|
||||
return float(m.group().replace(",", ""))
|
||||
except ValueError:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def parse_listing_html(html: str, product_id: str) -> dict:
|
||||
"""Parse a Mercari listing page into a raw seller dict."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
def _text(testid: str) -> str:
|
||||
el = soup.find(attrs={"data-testid": testid})
|
||||
return el.get_text(strip=True) if el else ""
|
||||
|
||||
username_raw = _text("ItemDetailsSellerUserName")
|
||||
username = username_raw.lstrip("@")
|
||||
|
||||
num_sales = _safe_int(_text("NumSales"))
|
||||
rating_count = _safe_int(_text("SellerRatingCount"))
|
||||
|
||||
stars = 0.0
|
||||
rw = soup.find(attrs={"data-testid": "ReviewStarsWrapper"})
|
||||
if rw:
|
||||
try:
|
||||
stars = float(rw.get("data-stars", 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
condition = _text("ItemDetailsCondition").lower()
|
||||
posted_text = _text("ItemDetailsPosted")
|
||||
listing_age_days = _parse_listing_age(posted_text)
|
||||
|
||||
price_text = _text("ItemPrice")
|
||||
price = 0.0
|
||||
m = _PRICE_RE.search(price_text.replace(",", ""))
|
||||
if m:
|
||||
try:
|
||||
price = float(m.group())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"product_id": product_id,
|
||||
"username": username,
|
||||
"num_sales": num_sales, # completed sales → maps to feedback_count
|
||||
"rating_count": rating_count, # number of reviews (additional signal)
|
||||
"stars": stars, # 0.0–5.0 → divide by 5 = feedback_ratio
|
||||
"condition": condition,
|
||||
"listing_age_days": listing_age_days,
|
||||
"price": price,
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(text: str) -> int:
|
||||
m = _PRICE_RE.search(text.replace(",", ""))
|
||||
if m:
|
||||
try:
|
||||
return int(float(m.group()))
|
||||
except ValueError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_listing_age(posted_text: str) -> int:
|
||||
"""Convert a posted date like '04/10/26' to days since posted."""
|
||||
from datetime import datetime, timezone
|
||||
m = _POSTED_RE.search(posted_text)
|
||||
if not m:
|
||||
return 0
|
||||
try:
|
||||
month, day, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
if year < 100:
|
||||
year += 2000
|
||||
posted = datetime(year, month, day, tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - posted).days
|
||||
except (ValueError, OverflowError):
|
||||
return 0
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
# app/tasks/monitor.py
|
||||
"""Background saved-search monitor — polls eBay and writes WatchAlerts for new listings.
|
||||
|
||||
Design notes:
|
||||
- Runs synchronously inside an asyncio.to_thread() call from the polling loop.
|
||||
- Uses the same eBay adapter + trust scoring pipeline as the live search endpoint.
|
||||
- Dedup via watch_alerts (saved_search_id, platform_listing_id) UNIQUE constraint.
|
||||
- Never takes any transactional action — alert only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.db.models import SavedSearch, WatchAlert
|
||||
from app.db.store import Store
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_AUCTION_ALERT_WINDOW_HOURS = 24 # alert on auctions ending within this window
|
||||
|
||||
|
||||
def should_alert(
|
||||
*,
|
||||
trust_score: int,
|
||||
score_is_partial: bool,
|
||||
price: float,
|
||||
buying_format: str,
|
||||
min_trust_score: int,
|
||||
ends_at: "str | None" = None,
|
||||
) -> bool:
|
||||
"""Return True if a listing qualifies for a watch alert.
|
||||
|
||||
BIN (fixed_price / best_offer): alert immediately — these sell on a first-come
|
||||
basis, so speed matters. Require a higher trust bar on partial scores to reduce
|
||||
false positives while BTF scraping is still in flight.
|
||||
|
||||
Auction: only alert when the auction is within _AUCTION_ALERT_WINDOW_HOURS of
|
||||
ending. Alerting on a 7-day auction 6 days early is noise — the user can't act
|
||||
usefully until the end window anyway. Bid scheduling (paid+) and sniping algo
|
||||
(premium) are separate features built on top of this alert layer.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Partial scores: apply a +10 buffer so we don't surface unreliable signals.
|
||||
effective_min = min_trust_score + 10 if score_is_partial else min_trust_score
|
||||
if trust_score < effective_min:
|
||||
return False
|
||||
|
||||
if buying_format in ("fixed_price", "best_offer"):
|
||||
# BIN: alert immediately — inventory can disappear any time.
|
||||
return True
|
||||
|
||||
if buying_format == "auction":
|
||||
if not ends_at:
|
||||
# No end time recorded — alert anyway rather than silently skip.
|
||||
return True
|
||||
try:
|
||||
end = datetime.fromisoformat(ends_at.replace("Z", "+00:00"))
|
||||
hours_remaining = (end - datetime.now(timezone.utc)).total_seconds() / 3600
|
||||
return 0 < hours_remaining <= _AUCTION_ALERT_WINDOW_HOURS
|
||||
except (ValueError, TypeError):
|
||||
log.debug("should_alert: could not parse ends_at=%r, alerting anyway", ends_at)
|
||||
return True
|
||||
|
||||
# Unknown format — alert and let the user decide.
|
||||
return True
|
||||
|
||||
|
||||
def run_monitor_search(
|
||||
search: SavedSearch,
|
||||
*,
|
||||
user_db: Path,
|
||||
shared_db: Path,
|
||||
) -> int:
|
||||
"""Execute one background monitor run for a saved search.
|
||||
|
||||
Fetches current listings, scores them, writes new high-trust finds
|
||||
to watch_alerts. Returns the count of new alerts written.
|
||||
|
||||
Called from the async polling loop via asyncio.to_thread().
|
||||
"""
|
||||
from app.platforms.ebay.adapter import EbayAdapter
|
||||
from app.trust import TrustScorer
|
||||
|
||||
log.info("Monitor: checking saved search %d (%r)", search.id, search.name)
|
||||
|
||||
filters = json.loads(search.filters_json or "{}")
|
||||
query = filters.pop("query_raw", search.query)
|
||||
|
||||
try:
|
||||
adapter = EbayAdapter()
|
||||
raw_listings = adapter.search(query, **filters)
|
||||
except Exception as exc:
|
||||
log.warning("Monitor: eBay search failed for search %d: %s", search.id, exc)
|
||||
return 0
|
||||
|
||||
shared_store = Store(shared_db)
|
||||
user_store = Store(user_db)
|
||||
scorer = TrustScorer(shared_store)
|
||||
|
||||
try:
|
||||
trust_scores = scorer.score_batch(raw_listings, query)
|
||||
except Exception as exc:
|
||||
log.warning("Monitor: trust scoring failed for search %d: %s", search.id, exc)
|
||||
return 0
|
||||
|
||||
new_alert_count = 0
|
||||
for listing, trust in zip(raw_listings, trust_scores):
|
||||
qualifies = should_alert(
|
||||
trust_score=trust.composite_score,
|
||||
score_is_partial=trust.score_is_partial,
|
||||
price=listing.price,
|
||||
buying_format=listing.buying_format,
|
||||
min_trust_score=search.min_trust_score,
|
||||
ends_at=listing.ends_at,
|
||||
)
|
||||
if not qualifies:
|
||||
continue
|
||||
|
||||
alert = WatchAlert(
|
||||
saved_search_id=search.id,
|
||||
platform_listing_id=listing.platform_listing_id,
|
||||
title=listing.title,
|
||||
price=listing.price,
|
||||
currency=listing.currency,
|
||||
trust_score=trust.composite_score,
|
||||
url=listing.url,
|
||||
)
|
||||
_, is_new = user_store.upsert_alert(alert)
|
||||
if is_new:
|
||||
new_alert_count += 1
|
||||
log.info(
|
||||
"Monitor: new alert — search %d, listing %s, score=%d",
|
||||
search.id, listing.platform_listing_id, trust.composite_score,
|
||||
)
|
||||
|
||||
user_store.mark_search_checked(search.id)
|
||||
log.info(
|
||||
"Monitor: search %d done — %d new alerts from %d listings",
|
||||
search.id, new_alert_count, len(raw_listings),
|
||||
)
|
||||
return new_alert_count
|
||||
|
|
@ -7,30 +7,28 @@ Current task types:
|
|||
trust_photo_analysis — download primary photo, run vision LLM, write
|
||||
result to trust_scores.photo_analysis_json (Paid tier).
|
||||
|
||||
Image assessment routing:
|
||||
Cloud (GPU_SERVER_URL set): allocates via cf-orch task endpoint
|
||||
product=snipe, task=image_assessment.
|
||||
Local (no GPU_SERVER_URL) or TaskNotFound fallback: uses LLMRouter
|
||||
with a vision-capable local backend (moondream2, llava, etc.).
|
||||
Prompt note: The vision prompt is a functional first pass. Tune against real
|
||||
eBay listings before GA — specifically stock-photo vs genuine-product distinction
|
||||
and the damage vocabulary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from circuitforge_core.db import get_connection
|
||||
from circuitforge_core.llm import LLMRouter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LLM_TASK_TYPES: frozenset[str] = frozenset({"trust_photo_analysis"})
|
||||
|
||||
VRAM_BUDGETS: dict[str, float] = {
|
||||
"trust_photo_analysis": 6000, # Q5_K_M Qwen2-VL via cf-orch; LLMRouter fallback uses 2.0 GB
|
||||
# moondream2 / vision-capable LLM — single image, short response
|
||||
"trust_photo_analysis": 2.0,
|
||||
}
|
||||
|
||||
_VISION_SYSTEM_PROMPT = (
|
||||
|
|
@ -53,7 +51,8 @@ def insert_task(
|
|||
) -> tuple[int, bool]:
|
||||
"""Insert a background task if no identical task is already in-flight.
|
||||
|
||||
Returns (task_id, is_new).
|
||||
Uses get_connection() so WAL mode and timeout=30 apply — same as all other
|
||||
Snipe DB access. Returns (task_id, is_new).
|
||||
"""
|
||||
conn = get_connection(db_path)
|
||||
conn.row_factory = __import__("sqlite3").Row
|
||||
|
|
@ -121,26 +120,32 @@ def _run_trust_photo_analysis(
|
|||
p = json.loads(params or "{}")
|
||||
photo_url = p.get("photo_url", "")
|
||||
listing_title = p.get("listing_title", "")
|
||||
# user_db: per-user DB in cloud mode; same as db_path in local mode.
|
||||
result_db = Path(p.get("user_db", str(db_path)))
|
||||
|
||||
if not photo_url:
|
||||
raise ValueError("trust_photo_analysis: 'photo_url' is required in params")
|
||||
|
||||
# Download and base64-encode the photo
|
||||
resp = requests.get(photo_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
image_b64 = base64.b64encode(resp.content).decode()
|
||||
image_data_url = f"data:image/jpeg;base64,{image_b64}"
|
||||
|
||||
user_prompt = "Assess this listing image."
|
||||
# Build user prompt with optional title context
|
||||
user_prompt = "Evaluate this eBay listing photo."
|
||||
if listing_title:
|
||||
user_prompt = f"Assess this eBay listing image: {listing_title}"
|
||||
user_prompt = f"Evaluate this eBay listing photo for: {listing_title}"
|
||||
|
||||
cforch_url = os.getenv("GPU_SERVER_URL") or os.getenv("CF_ORCH_URL")
|
||||
if cforch_url:
|
||||
raw = _assess_via_orch(cforch_url, image_data_url, user_prompt)
|
||||
else:
|
||||
raw = _assess_via_local_llm(image_b64, user_prompt)
|
||||
# Call LLMRouter with vision capability
|
||||
router = LLMRouter()
|
||||
raw = router.complete(
|
||||
user_prompt,
|
||||
system=_VISION_SYSTEM_PROMPT,
|
||||
images=[image_b64],
|
||||
max_tokens=128,
|
||||
)
|
||||
|
||||
# Parse — be lenient: strip markdown fences if present
|
||||
try:
|
||||
cleaned = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
analysis = json.loads(cleaned)
|
||||
|
|
@ -163,54 +168,3 @@ def _run_trust_photo_analysis(
|
|||
analysis.get("visible_damage"),
|
||||
analysis.get("confidence"),
|
||||
)
|
||||
|
||||
|
||||
def _assess_via_orch(cforch_url: str, image_data_url: str, user_prompt: str) -> str:
|
||||
"""Run photo assessment via cf-orch task endpoint (cloud path)."""
|
||||
from circuitforge_orch.client import CFOrchClient, TaskNotFound
|
||||
|
||||
client = CFOrchClient(cforch_url)
|
||||
try:
|
||||
with client.task_allocate("snipe", "image_assessment") as alloc:
|
||||
resp = httpx.post(
|
||||
f"{alloc.url}/v1/chat/completions",
|
||||
json={
|
||||
"model": alloc.model or "__auto__",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": _VISION_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||
{"type": "text", "text": user_prompt},
|
||||
],
|
||||
},
|
||||
],
|
||||
"max_tokens": 128,
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
except TaskNotFound:
|
||||
log.warning(
|
||||
"snipe.image_assessment not registered in cf-orch — falling back to local LLM"
|
||||
)
|
||||
image_b64 = image_data_url.split(",", 1)[1]
|
||||
return _assess_via_local_llm(image_b64, user_prompt)
|
||||
|
||||
|
||||
def _assess_via_local_llm(image_b64: str, user_prompt: str) -> str:
|
||||
"""Run photo assessment via local LLMRouter (local/self-hosted path)."""
|
||||
from app.llm.router import LLMRouter
|
||||
|
||||
router = LLMRouter()
|
||||
return router.complete(
|
||||
user_prompt,
|
||||
system=_VISION_SYSTEM_PROMPT,
|
||||
images=[image_b64],
|
||||
max_tokens=128,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import hashlib
|
|||
import math
|
||||
|
||||
from app.db.models import Listing, TrustScore
|
||||
from app.db.protocol import SharedTableProtocol
|
||||
from app.db.store import Store
|
||||
|
||||
from .aggregator import Aggregator
|
||||
from .metadata import MetadataScorer
|
||||
|
|
@ -12,7 +12,7 @@ from .photo import PhotoScorer
|
|||
class TrustScorer:
|
||||
"""Orchestrates metadata + photo scoring for a batch of listings."""
|
||||
|
||||
def __init__(self, shared_store: SharedTableProtocol):
|
||||
def __init__(self, shared_store: Store):
|
||||
self._store = shared_store
|
||||
self._meta = MetadataScorer()
|
||||
self._photo = PhotoScorer()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.db.models import Seller, TrustScore
|
||||
|
|
@ -11,15 +11,6 @@ HARD_FILTER_AGE_DAYS = 7
|
|||
HARD_FILTER_BAD_RATIO_MIN_COUNT = 20
|
||||
HARD_FILTER_BAD_RATIO_THRESHOLD = 0.80
|
||||
|
||||
# Above this lifetime count the 12-month ratio may cover only a tiny recent sample,
|
||||
# making a hard bad-actor flag disproportionate. Instead we emit the softer
|
||||
# "declining_ratio" flag and let the composite score carry the penalty.
|
||||
# Note: buyer-feedback-only accounts (e.g. longtime buyers who start selling) are a
|
||||
# related edge case that requires profile-page scraping to detect properly — tracked
|
||||
# in snipe#52 as a medium-term fix.
|
||||
HARD_FILTER_BAD_RATIO_MAX_COUNT = 500
|
||||
HARD_FILTER_BAD_RATIO_HIGH_THRESHOLD = 0.60 # catastrophically bad even for high-volume
|
||||
|
||||
# Sellers above this feedback count are treated as established retailers.
|
||||
# Stock photo reuse (duplicate_photo) is suppressed for them — large retailers
|
||||
# legitimately share manufacturer images across many listings.
|
||||
|
|
@ -69,9 +60,9 @@ def _days_since(iso: Optional[str]) -> Optional[int]:
|
|||
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
# Normalize to naive UTC so both paths (timezone-aware ISO and SQLite
|
||||
# CURRENT_TIMESTAMP naive strings) compare correctly.
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - dt).days
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.replace(tzinfo=None)
|
||||
return (datetime.utcnow() - dt).days
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
|
@ -126,23 +117,11 @@ class Aggregator:
|
|||
# Hard filters
|
||||
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
|
||||
red_flags.append("new_account")
|
||||
if seller and seller.feedback_ratio == 0.0 and seller.feedback_count > 0:
|
||||
# 12-month ratio missing from page — returning seller or buyer-only account.
|
||||
# Score will be partial (metadata._feedback_ratio returns None). Soft flag
|
||||
# only: do NOT fire established_bad_actor on what is likely missing data.
|
||||
red_flags.append("no_recent_seller_data")
|
||||
elif seller and seller.feedback_ratio < HARD_FILTER_BAD_RATIO_THRESHOLD:
|
||||
if HARD_FILTER_BAD_RATIO_MIN_COUNT < seller.feedback_count <= HARD_FILTER_BAD_RATIO_MAX_COUNT:
|
||||
# Moderate-volume account with consistently bad ratio → hard flag.
|
||||
red_flags.append("established_bad_actor")
|
||||
elif seller.feedback_count > HARD_FILTER_BAD_RATIO_MAX_COUNT:
|
||||
if seller.feedback_ratio < HARD_FILTER_BAD_RATIO_HIGH_THRESHOLD:
|
||||
# High-volume seller with catastrophic ratio → still hard flag.
|
||||
red_flags.append("established_bad_actor")
|
||||
else:
|
||||
# High-volume seller with declining but not catastrophic ratio.
|
||||
# 12-month window may cover only a small recent sample — soft flag only.
|
||||
red_flags.append("declining_ratio")
|
||||
if seller and (
|
||||
seller.feedback_ratio < HARD_FILTER_BAD_RATIO_THRESHOLD
|
||||
and seller.feedback_count > HARD_FILTER_BAD_RATIO_MIN_COUNT
|
||||
):
|
||||
red_flags.append("established_bad_actor")
|
||||
if seller and seller.feedback_count == 0:
|
||||
red_flags.append("zero_feedback")
|
||||
# Zero feedback is a deliberate signal, not missing data — cap composite score
|
||||
|
|
|
|||
|
|
@ -44,13 +44,7 @@ class MetadataScorer:
|
|||
if count < 200: return 15
|
||||
return 20
|
||||
|
||||
def _feedback_ratio(self, ratio: float, count: int) -> Optional[int]:
|
||||
# ratio=0.0 with count>0 means the 12-month percentage wasn't on the page —
|
||||
# eBay omits the ratio for returning/buyer-only sellers with no recent sales.
|
||||
# Treat as missing rather than "literally 0% positive" (which eBay doesn't allow
|
||||
# on active accounts — those get suspended long before reaching 0%).
|
||||
if ratio == 0.0 and count > 0:
|
||||
return None
|
||||
def _feedback_ratio(self, ratio: float, count: int) -> int:
|
||||
if ratio < 0.80 and count > 20: return 0
|
||||
if ratio < 0.90: return 5
|
||||
if ratio < 0.95: return 10
|
||||
|
|
|
|||
|
|
@ -20,13 +20,9 @@ services:
|
|||
CLOUD_MODE: "true"
|
||||
CLOUD_DATA_ROOT: /devl/snipe-cloud-data
|
||||
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env (never commit)
|
||||
# GPU_SERVER_URL routes LLM query builder through cf-orch for VRAM-aware scheduling.
|
||||
# CF_ORCH_URL routes LLM query builder through cf-orch for VRAM-aware scheduling.
|
||||
# Override in .env to use a different coordinator URL.
|
||||
GPU_SERVER_URL: "http://host.docker.internal:7700"
|
||||
# SNIPE_SHARED_DB_URL — Postgres DSN for shared tables (sellers, market_comps, blocklist).
|
||||
# Required for production multi-user deployments. Set in .env (never commit).
|
||||
# SNIPE_SHARED_DB_URL: "postgresql://snipe:<password>@postgres:5432/snipe_shared"
|
||||
CF_APP_NAME: snipe
|
||||
CF_ORCH_URL: "http://host.docker.internal:7700"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# No network_mode: host — isolated on snipe-cloud-net; nginx reaches it via 'api:8510'
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ services:
|
|||
environment:
|
||||
- RELOAD=true
|
||||
# Point the LLM/vision task scheduler at the local cf-orch coordinator.
|
||||
# Only has effect when GPU_SERVER_URL is set (uncomment in .env, or set inline).
|
||||
# - GPU_SERVER_URL=http://10.1.10.71:7700
|
||||
# Only has effect when CF_ORCH_URL is set (uncomment in .env, or set inline).
|
||||
# - CF_ORCH_URL=http://10.1.10.71:7700
|
||||
|
||||
# cf-orch agent — routes trust_photo_analysis vision tasks to the GPU coordinator.
|
||||
# Only starts when you pass --profile orch:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
# (claude_code, copilot) are intentionally excluded here.
|
||||
#
|
||||
# CF Orchestrator routes both ollama and vllm allocations for VRAM-aware
|
||||
# scheduling. GPU_SERVER_URL must be set in .env for allocations to resolve;
|
||||
# scheduling. CF_ORCH_URL must be set in .env for allocations to resolve;
|
||||
# if cf-orch is unreachable the backend falls back to its static base_url.
|
||||
#
|
||||
# Model choice for query builder: llama3.1:8b
|
||||
|
|
|
|||
|
|
@ -39,21 +39,6 @@ backends:
|
|||
# service: ollama
|
||||
# ttl_s: 300
|
||||
|
||||
# ── cf-orch trunk services ─────────────────────────────────────────────────
|
||||
# Allocate via cf-orch; the router calls the allocated service directly.
|
||||
# Set CF_ORCH_URL (env) or url below to activate.
|
||||
cf_text:
|
||||
type: openai_compat
|
||||
enabled: false
|
||||
base_url: http://localhost:8008/v1
|
||||
model: __auto__
|
||||
api_key: any
|
||||
supports_images: false
|
||||
cf_orch:
|
||||
service: cf-text
|
||||
model_candidates: []
|
||||
ttl_s: 3600
|
||||
|
||||
fallback_order:
|
||||
- anthropic
|
||||
- openai
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ server {
|
|||
# Forward the session header injected by Caddy from the cf_session cookie.
|
||||
# Caddy adds: header_up X-CF-Session {http.request.cookie.cf_session}
|
||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||
# eBay search + comps can take 60-90s (Marketplace Insights 404 → Browse fallback).
|
||||
# Default 60s proxy_read_timeout drops slow searches with a NetworkError on the client.
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# index.html — never cache; ensures clients always get the latest entry point
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
(function(){var s=document.createElement("script");s.defer=true;s.dataset.domain="docs.circuitforge.tech,circuitforge.tech";s.dataset.api="https://analytics.circuitforge.tech/api/event";s.src="https://analytics.circuitforge.tech/js/script.js";document.head.appendChild(s);})();
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 164 KiB |
|
|
@ -61,6 +61,3 @@ nav:
|
|||
- Trust Score Algorithm: reference/trust-scoring.md
|
||||
- Tier System: reference/tier-system.md
|
||||
- Architecture: reference/architecture.md
|
||||
|
||||
extra_javascript:
|
||||
- plausible.js
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ version = "0.3.0"
|
|||
description = "Auction listing monitor and trust scorer"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"circuitforge-core[community]>=0.8.0",
|
||||
"circuitforge-core>=0.8.0",
|
||||
"streamlit>=1.32",
|
||||
"requests>=2.31",
|
||||
"imagehash>=4.3",
|
||||
|
|
@ -23,20 +23,14 @@ dependencies = [
|
|||
"playwright-stealth>=1.0",
|
||||
"cryptography>=42.0",
|
||||
"PyJWT>=2.8",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
orchestration = [
|
||||
# Paid+ tier only — not published to PyPI. Install from source or Forgejo Packages.
|
||||
# pip install -e ../circuitforge-orch (dev)
|
||||
# pip install snipe[orchestration] (self-hosted Paid+)
|
||||
"circuitforge-orch>=0.1.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=5.0",
|
||||
"ruff>=0.4",
|
||||
"httpx>=0.27", # FastAPI test client
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
"""Reproduce the exact FastAPI code path: pool warmup → slot close → _fetch_fresh.
|
||||
|
||||
Run inside the container:
|
||||
docker exec -it snipe-api-1 python /app/snipe/scripts/debug_fetch_fresh.py
|
||||
"""
|
||||
import sys, time, threading
|
||||
sys.path.insert(0, '/app/snipe')
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from app.platforms.ebay.browser_pool import BrowserPool, _close_slot
|
||||
|
||||
URL = "https://www.mercari.com/search/?keyword=rtx+4090&sortBy=SORT_SCORE&priceMax=800"
|
||||
|
||||
print("=== Test 1: _fetch_fresh with no pool (baseline) ===", flush=True)
|
||||
pool0 = BrowserPool(size=0)
|
||||
t0 = time.time()
|
||||
html = pool0._fetch_fresh(URL, wait_for_timeout_ms=8000)
|
||||
items = BeautifulSoup(html, "html.parser").find_all(attrs={"data-testid": "ItemContainer"})
|
||||
print(f"Items: {len(items)}, HTML: {len(html)}b, elapsed: {time.time()-t0:.1f}s", flush=True)
|
||||
|
||||
print("\n=== Test 2: pool warmup (size=2), grab slot, close it, then _fetch_fresh ===", flush=True)
|
||||
pool2 = BrowserPool(size=2)
|
||||
|
||||
# Warmup in background (blocks until done)
|
||||
warm_done = threading.Event()
|
||||
def do_warmup():
|
||||
pool2.start()
|
||||
warm_done.set()
|
||||
|
||||
t = threading.Thread(target=do_warmup, daemon=True)
|
||||
t.start()
|
||||
warm_done.wait(timeout=30)
|
||||
print(f"Pool size after warmup: {pool2._q.qsize()}", flush=True)
|
||||
|
||||
# Grab a slot and close it (simulating the thread-error path)
|
||||
import queue
|
||||
try:
|
||||
slot = pool2._q.get(timeout=3.0)
|
||||
print(f"Got slot on display :{slot.display_num}", flush=True)
|
||||
_close_slot(slot)
|
||||
print("Slot closed", flush=True)
|
||||
except queue.Empty:
|
||||
print("Pool empty — no slot to simulate", flush=True)
|
||||
|
||||
# Now call _fetch_fresh in this thread (same as FastAPI handler thread)
|
||||
print("Calling _fetch_fresh from warmup-thread context...", flush=True)
|
||||
t0 = time.time()
|
||||
html2 = pool2._fetch_fresh(URL, wait_for_timeout_ms=8000)
|
||||
items2 = BeautifulSoup(html2, "html.parser").find_all(attrs={"data-testid": "ItemContainer"})
|
||||
print(f"Items: {len(items2)}, HTML: {len(html2)}b, elapsed: {time.time()-t0:.1f}s", flush=True)
|
||||
|
||||
# Save HTML for inspection if empty
|
||||
if len(items2) == 0:
|
||||
with open("/tmp/debug_mercari.html", "w") as f:
|
||||
f.write(html2)
|
||||
print("Saved HTML to /tmp/debug_mercari.html", flush=True)
|
||||
title = BeautifulSoup(html2, "html.parser").find("title")
|
||||
print("Page title:", title.get_text() if title else "(none)", flush=True)
|
||||
if "Just a moment" in html2 or "turnstile" in html2.lower():
|
||||
print("BLOCKED: Cloudflare challenge", flush=True)
|
||||
else:
|
||||
body = BeautifulSoup(html2, "html.parser").find("body")
|
||||
if body:
|
||||
print("Body snippet:", body.get_text(separator=" ", strip=True)[:300], flush=True)
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"""One-shot Mercari probe using the same headed Chromium + Xvfb + stealth stack
|
||||
as the eBay scraper. Run inside the snipe-api container:
|
||||
|
||||
docker exec -it snipe-api-1 python /app/scripts/probe_mercari.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
_display_counter = itertools.count(200)
|
||||
_CHROMIUM_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
SEARCH_URL = "https://www.mercari.com/search/?keyword=rtx+4090"
|
||||
# Give Cloudflare challenge time to resolve (if it does)
|
||||
WAIT_MS = 8_000
|
||||
|
||||
|
||||
def probe(url: str) -> str:
|
||||
from playwright.sync_api import sync_playwright
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
display_num = next(_display_counter)
|
||||
display = f":{display_num}"
|
||||
env = os.environ.copy()
|
||||
env["DISPLAY"] = display
|
||||
|
||||
xvfb = subprocess.Popen(
|
||||
["Xvfb", display, "-screen", "0", "1280x800x24"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(
|
||||
headless=False,
|
||||
env=env,
|
||||
args=_CHROMIUM_ARGS,
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent=_USER_AGENT,
|
||||
viewport={"width": 1280, "height": 800},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
print(f"[probe] Navigating to {url} …", flush=True)
|
||||
response = page.goto(url, wait_until="domcontentloaded", timeout=40_000)
|
||||
print(f"[probe] HTTP status: {response.status if response else 'unknown'}", flush=True)
|
||||
print(f"[probe] Waiting {WAIT_MS}ms for JS / Turnstile …", flush=True)
|
||||
page.wait_for_timeout(WAIT_MS)
|
||||
html = page.content()
|
||||
title = page.title()
|
||||
print(f"[probe] Page title: {title!r}", flush=True)
|
||||
browser.close()
|
||||
finally:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def analyse(html: str) -> None:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# Cloudflare challenge indicators
|
||||
if "Just a moment" in html or "cf-challenge" in html or "turnstile" in html.lower():
|
||||
print("[result] BLOCKED — Cloudflare Turnstile still active")
|
||||
return
|
||||
|
||||
print("[result] Cloudflare challenge NOT detected — page appears to have loaded")
|
||||
|
||||
# Try to find listing cards
|
||||
# Mercari US uses data-testid or item cards in the DOM
|
||||
candidates = [
|
||||
soup.select("[data-testid='ItemCell']"),
|
||||
soup.select("[data-testid='item-cell']"),
|
||||
soup.select("li[data-testid]"),
|
||||
soup.select(".merList .merListItem"),
|
||||
soup.select("[class*='ItemCell']"),
|
||||
soup.select("[class*='item-cell']"),
|
||||
]
|
||||
for sel_result in candidates:
|
||||
if sel_result:
|
||||
print(f"[result] Found {len(sel_result)} listing card(s) via selector")
|
||||
card = sel_result[0]
|
||||
print(f"[result] First card snippet:\n{card.prettify()[:800]}")
|
||||
return
|
||||
|
||||
# Fallback: show body text summary
|
||||
body = soup.find("body")
|
||||
text = body.get_text(separator=" ", strip=True)[:500] if body else html[:500]
|
||||
print(f"[result] No listing cards found. Body text preview:\n{text}")
|
||||
# Save full HTML for manual inspection
|
||||
out = "/tmp/mercari_probe.html"
|
||||
with open(out, "w") as fh:
|
||||
fh.write(html)
|
||||
print(f"[result] Full HTML saved to {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
html = probe(SEARCH_URL)
|
||||
analyse(html)
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"postgres: mark test as requiring a live Postgres instance (SNIPE_SHARED_DB_URL must be set)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_dsn():
|
||||
dsn = os.environ.get("SNIPE_SHARED_DB_URL")
|
||||
if not dsn:
|
||||
pytest.skip("SNIPE_SHARED_DB_URL not set — skipping Postgres tests")
|
||||
return dsn
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
"""Tests for SnipeSharedStore — requires live Postgres via SNIPE_SHARED_DB_URL."""
|
||||
import pytest
|
||||
from app.db.models import MarketComp, Seller
|
||||
from app.db.pg_shared import SnipeSharedDB, SnipeSharedStore
|
||||
from app.db.protocol import SharedTableProtocol
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_snipe_shared_store_satisfies_protocol(postgres_dsn):
|
||||
assert issubclass(SnipeSharedStore, SharedTableProtocol)
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_save_and_get_seller(postgres_dsn):
|
||||
db = SnipeSharedDB(postgres_dsn)
|
||||
db.run_migrations()
|
||||
store = SnipeSharedStore(db)
|
||||
|
||||
seller = Seller(
|
||||
platform="ebay",
|
||||
platform_seller_id="test-seller-001",
|
||||
username="testseller",
|
||||
account_age_days=365,
|
||||
feedback_count=100,
|
||||
feedback_ratio=0.99,
|
||||
category_history_json='{"electronics": 5}',
|
||||
)
|
||||
store.save_seller(seller)
|
||||
|
||||
result = store.get_seller("ebay", "test-seller-001")
|
||||
assert result is not None
|
||||
assert result.username == "testseller"
|
||||
assert result.feedback_count == 100
|
||||
|
||||
store.delete_seller_data("ebay", "test-seller-001")
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_save_sellers_coalesce_preserves_age(postgres_dsn):
|
||||
db = SnipeSharedDB(postgres_dsn)
|
||||
db.run_migrations()
|
||||
store = SnipeSharedStore(db)
|
||||
|
||||
seller_with_age = Seller(
|
||||
platform="ebay", platform_seller_id="coalesce-test",
|
||||
username="u", account_age_days=730,
|
||||
feedback_count=50, feedback_ratio=0.95, category_history_json="{}",
|
||||
)
|
||||
store.save_seller(seller_with_age)
|
||||
|
||||
seller_without_age = Seller(
|
||||
platform="ebay", platform_seller_id="coalesce-test",
|
||||
username="u", account_age_days=None,
|
||||
feedback_count=60, feedback_ratio=0.96, category_history_json="{}",
|
||||
)
|
||||
store.save_sellers([seller_without_age])
|
||||
|
||||
result = store.get_seller("ebay", "coalesce-test")
|
||||
assert result.account_age_days == 730
|
||||
assert result.feedback_count == 60
|
||||
|
||||
store.delete_seller_data("ebay", "coalesce-test")
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_market_comp_cache(postgres_dsn):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
db = SnipeSharedDB(postgres_dsn)
|
||||
db.run_migrations()
|
||||
store = SnipeSharedStore(db)
|
||||
|
||||
expires = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
comp = MarketComp(
|
||||
platform="ebay", query_hash="abc123",
|
||||
median_price=49.99, sample_count=10, expires_at=expires,
|
||||
)
|
||||
store.save_market_comp(comp)
|
||||
|
||||
result = store.get_market_comp("ebay", "abc123")
|
||||
assert result is not None
|
||||
assert result.median_price == 49.99
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_reported_sellers(postgres_dsn):
|
||||
db = SnipeSharedDB(postgres_dsn)
|
||||
db.run_migrations()
|
||||
store = SnipeSharedStore(db)
|
||||
|
||||
store.mark_reported("ebay", "bad-seller-99", username="badguy")
|
||||
reported = store.list_reported("ebay")
|
||||
assert "bad-seller-99" in reported
|
||||
|
||||
store.mark_reported("ebay", "bad-seller-99") # idempotent
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_clone_returns_self(postgres_dsn):
|
||||
db = SnipeSharedDB(postgres_dsn)
|
||||
store = SnipeSharedStore(db)
|
||||
assert store.clone() is store
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_blocklist_add_get_remove(postgres_dsn):
|
||||
from app.db.models import ScammerEntry
|
||||
db = SnipeSharedDB(postgres_dsn)
|
||||
db.run_migrations()
|
||||
store = SnipeSharedStore(db)
|
||||
|
||||
assert not store.is_blocklisted("ebay", "bad-999")
|
||||
|
||||
entry = store.add_to_blocklist(ScammerEntry(
|
||||
platform="ebay", platform_seller_id="bad-999",
|
||||
username="scammer1", reason="sold fakes", source="manual",
|
||||
))
|
||||
assert entry.id is not None
|
||||
assert store.is_blocklisted("ebay", "bad-999")
|
||||
|
||||
entries = store.list_blocklist("ebay")
|
||||
assert any(e.platform_seller_id == "bad-999" for e in entries)
|
||||
|
||||
store.remove_from_blocklist("ebay", "bad-999")
|
||||
assert not store.is_blocklisted("ebay", "bad-999")
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_blocklist_upsert_is_idempotent(postgres_dsn):
|
||||
from app.db.models import ScammerEntry
|
||||
db = SnipeSharedDB(postgres_dsn)
|
||||
db.run_migrations()
|
||||
store = SnipeSharedStore(db)
|
||||
|
||||
store.add_to_blocklist(ScammerEntry(
|
||||
platform="ebay", platform_seller_id="dup-test",
|
||||
username="seller", reason="reason1", source="manual",
|
||||
))
|
||||
# Second add — should not raise, should update username but preserve reason via COALESCE
|
||||
store.add_to_blocklist(ScammerEntry(
|
||||
platform="ebay", platform_seller_id="dup-test",
|
||||
username="seller_updated", reason=None, source="community",
|
||||
))
|
||||
entries = [e for e in store.list_blocklist("ebay") if e.platform_seller_id == "dup-test"]
|
||||
assert len(entries) == 1
|
||||
assert entries[0].username == "seller_updated"
|
||||
assert entries[0].reason == "reason1" # COALESCE preserved original reason
|
||||
|
||||
store.remove_from_blocklist("ebay", "dup-test")
|
||||
db.close()
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"""Verify Store satisfies SharedTableProtocol at import time."""
|
||||
from app.db.protocol import SharedTableProtocol
|
||||
from app.db.store import Store
|
||||
|
||||
|
||||
def test_store_satisfies_protocol():
|
||||
assert issubclass(Store, SharedTableProtocol)
|
||||
|
||||
|
||||
def test_store_clone_returns_new_instance(tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
s = Store(db)
|
||||
clone = s.clone()
|
||||
assert isinstance(clone, Store)
|
||||
assert clone is not s
|
||||
assert clone._db_path == db
|
||||
|
||||
|
||||
def test_ebay_adapter_accepts_protocol():
|
||||
from app.platforms.ebay.adapter import EbayAdapter
|
||||
import tempfile
|
||||
import pathlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
s = Store(pathlib.Path(tmp) / "t.db")
|
||||
adapter = EbayAdapter(token_manager=MagicMock(), shared_store=s)
|
||||
assert adapter._store is s
|
||||
|
||||
|
||||
def test_scraped_adapter_no_db_path_ref():
|
||||
from app.platforms.ebay.scraper import ScrapedEbayAdapter
|
||||
import tempfile
|
||||
import pathlib
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
s = Store(pathlib.Path(tmp) / "t.db")
|
||||
adapter = ScrapedEbayAdapter(shared_store=s)
|
||||
assert not hasattr(adapter, '_db_path_ref')
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
"""Tests for app.platforms.ebay.browser_pool (thread-local design).
|
||||
|
||||
All tests run without real Chromium / Xvfb / Playwright.
|
||||
Playwright, Xvfb subprocess calls, and Stealth are mocked throughout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to reset the module-level singleton between tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _reset_pool_singleton():
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
_mod._pool = None
|
||||
|
||||
|
||||
def _reset_thread_local():
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
_mod._thread_local.slot = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_pool():
|
||||
_reset_pool_singleton()
|
||||
_reset_thread_local()
|
||||
yield
|
||||
_reset_pool_singleton()
|
||||
_reset_thread_local()
|
||||
|
||||
|
||||
def _make_fake_slot():
|
||||
from app.platforms.ebay.browser_pool import _PooledBrowser
|
||||
|
||||
xvfb = MagicMock(spec=subprocess.Popen)
|
||||
pw = MagicMock()
|
||||
browser = MagicMock()
|
||||
ctx = MagicMock()
|
||||
return _PooledBrowser(
|
||||
xvfb=xvfb, pw=pw, browser=browser, ctx=ctx,
|
||||
display_num=100, last_used_ts=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetPoolSingleton:
|
||||
def test_returns_same_instance(self):
|
||||
from app.platforms.ebay.browser_pool import get_pool, BrowserPool
|
||||
assert get_pool() is get_pool()
|
||||
|
||||
def test_returns_browser_pool_instance(self):
|
||||
from app.platforms.ebay.browser_pool import get_pool, BrowserPool
|
||||
assert isinstance(get_pool(), BrowserPool)
|
||||
|
||||
def test_default_size_is_two(self):
|
||||
from app.platforms.ebay.browser_pool import get_pool
|
||||
assert get_pool()._size == 2
|
||||
|
||||
def test_custom_size_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("BROWSER_POOL_SIZE", "5")
|
||||
from app.platforms.ebay.browser_pool import get_pool
|
||||
assert get_pool()._size == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start() / stop() lifecycle tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLifecycle:
|
||||
def test_start_is_noop_when_playwright_unavailable(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=2)
|
||||
with patch.object(pool, "_check_playwright", return_value=False):
|
||||
pool.start()
|
||||
assert pool._started is True
|
||||
assert pool._slot_registry == {}
|
||||
|
||||
def test_start_only_runs_once(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
with patch.object(pool, "_check_playwright", return_value=False):
|
||||
pool.start()
|
||||
pool.start()
|
||||
assert pool._started is True
|
||||
|
||||
def test_stop_closes_all_registry_slots(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=2)
|
||||
slot1 = _make_fake_slot()
|
||||
slot2 = _make_fake_slot()
|
||||
pool._slot_registry[1001] = slot1
|
||||
pool._slot_registry[1002] = slot2
|
||||
|
||||
with patch("app.platforms.ebay.browser_pool._close_slot") as mock_close:
|
||||
pool.stop()
|
||||
|
||||
assert mock_close.call_count == 2
|
||||
assert pool._slot_registry == {}
|
||||
assert pool._stopped is True
|
||||
|
||||
def test_stop_on_empty_registry_is_safe(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
BrowserPool(size=2).stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_html — thread-local slot hit path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFetchHtmlSlotHit:
|
||||
def test_uses_existing_slot_and_replenishes(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
slot = _make_fake_slot()
|
||||
_mod._thread_local.slot = slot
|
||||
|
||||
fresh_slot = _make_fake_slot()
|
||||
|
||||
with (
|
||||
patch.object(pool, "_fetch_with_slot", return_value="<html>ok</html>") as mock_fetch,
|
||||
patch("app.platforms.ebay.browser_pool._replenish_slot", return_value=fresh_slot),
|
||||
patch.object(pool, "_register_slot") as mock_register,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=test", delay=0)
|
||||
|
||||
assert html == "<html>ok</html>"
|
||||
mock_fetch.assert_called_once_with(
|
||||
slot, "https://www.ebay.com/sch/i.html?_nkw=test",
|
||||
wait_for_selector=None, wait_for_timeout_ms=2000,
|
||||
)
|
||||
mock_register.assert_called_once_with(fresh_slot)
|
||||
|
||||
def test_delay_is_respected(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
_mod._thread_local.slot = _make_fake_slot()
|
||||
|
||||
with (
|
||||
patch.object(pool, "_fetch_with_slot", return_value="<html/>"),
|
||||
patch("app.platforms.ebay.browser_pool._replenish_slot", return_value=_make_fake_slot()),
|
||||
patch.object(pool, "_register_slot"),
|
||||
patch("app.platforms.ebay.browser_pool.time") as mock_time,
|
||||
):
|
||||
pool.fetch_html("https://example.com", delay=1.5)
|
||||
|
||||
mock_time.sleep.assert_called_once_with(1.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_html — no slot / fallback path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFetchHtmlFallback:
|
||||
def test_falls_back_when_no_slot_and_playwright_unavailable(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
# No thread-local slot; playwright unavailable → _get_or_create returns None.
|
||||
with (
|
||||
patch.object(pool, "_get_or_create_thread_slot", return_value=None),
|
||||
patch.object(pool, "_fetch_fresh", return_value="<html>fresh</html>") as mock_fresh,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=widget", delay=0)
|
||||
|
||||
assert html == "<html>fresh</html>"
|
||||
mock_fresh.assert_called_once_with(
|
||||
"https://www.ebay.com/sch/i.html?_nkw=widget",
|
||||
wait_for_selector=None, wait_for_timeout_ms=2000,
|
||||
)
|
||||
|
||||
def test_falls_back_when_pooled_fetch_raises(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
slot = _make_fake_slot()
|
||||
_mod._thread_local.slot = slot
|
||||
|
||||
with (
|
||||
patch.object(pool, "_fetch_with_slot", side_effect=RuntimeError("Chromium crashed")),
|
||||
patch.object(pool, "_fetch_fresh", return_value="<html>recovered</html>") as mock_fresh,
|
||||
patch("app.platforms.ebay.browser_pool._close_slot") as mock_close,
|
||||
patch.object(pool, "_unregister_slot"),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
html = pool.fetch_html("https://www.ebay.com/", delay=0)
|
||||
|
||||
assert html == "<html>recovered</html>"
|
||||
mock_close.assert_called_once_with(slot)
|
||||
mock_fresh.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-local slot management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestThreadLocalSlotManagement:
|
||||
def test_get_or_create_returns_existing_slot(self):
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
pool._playwright_available = True
|
||||
existing = _make_fake_slot()
|
||||
_mod._thread_local.slot = existing
|
||||
|
||||
result = pool._get_or_create_thread_slot()
|
||||
assert result is existing
|
||||
|
||||
def test_get_or_create_launches_new_slot_when_absent(self):
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
pool._playwright_available = True
|
||||
_mod._thread_local.slot = None
|
||||
|
||||
new_slot = _make_fake_slot()
|
||||
with (
|
||||
patch("app.platforms.ebay.browser_pool._launch_slot", return_value=new_slot),
|
||||
patch.object(pool, "_register_slot") as mock_register,
|
||||
):
|
||||
result = pool._get_or_create_thread_slot()
|
||||
|
||||
assert result is new_slot
|
||||
mock_register.assert_called_once_with(new_slot)
|
||||
|
||||
def test_get_or_create_returns_none_when_playwright_unavailable(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
pool._playwright_available = False
|
||||
assert pool._get_or_create_thread_slot() is None
|
||||
|
||||
def test_register_slot_sets_thread_local_and_registry(self):
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
slot = _make_fake_slot()
|
||||
pool._register_slot(slot)
|
||||
|
||||
assert _mod._thread_local.slot is slot
|
||||
assert threading.get_ident() in pool._slot_registry
|
||||
|
||||
def test_unregister_slot_clears_thread_local_and_registry(self):
|
||||
import app.platforms.ebay.browser_pool as _mod
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
slot = _make_fake_slot()
|
||||
pool._register_slot(slot)
|
||||
pool._unregister_slot()
|
||||
|
||||
assert getattr(_mod._thread_local, "slot", None) is None
|
||||
assert threading.get_ident() not in pool._slot_registry
|
||||
|
||||
def test_different_threads_get_independent_slots(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=2)
|
||||
pool._playwright_available = True
|
||||
|
||||
slots_seen: list = []
|
||||
errors: list = []
|
||||
|
||||
def worker():
|
||||
new_slot = _make_fake_slot()
|
||||
with patch("app.platforms.ebay.browser_pool._launch_slot", return_value=new_slot):
|
||||
s = pool._get_or_create_thread_slot()
|
||||
slots_seen.append(s)
|
||||
|
||||
t1 = threading.Thread(target=worker)
|
||||
t2 = threading.Thread(target=worker)
|
||||
t1.start(); t2.start()
|
||||
t1.join(); t2.join()
|
||||
|
||||
assert len(slots_seen) == 2
|
||||
# Each thread got its own slot object (they may differ or coincidentally share
|
||||
# the same mock; what matters is both threads succeeded without interference).
|
||||
assert all(s is not None for s in slots_seen)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ImportError graceful fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestImportErrorHandling:
|
||||
def test_check_playwright_returns_false_on_import_error(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=2)
|
||||
with patch.dict("sys.modules", {"playwright": None, "playwright_stealth": None}):
|
||||
pool._playwright_available = None
|
||||
result = pool._check_playwright()
|
||||
|
||||
assert result is False
|
||||
assert pool._playwright_available is False
|
||||
|
||||
def test_start_logs_warning_when_playwright_missing(self, caplog):
|
||||
import logging
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
pool._playwright_available = False
|
||||
|
||||
with patch.object(pool, "_check_playwright", return_value=False):
|
||||
with caplog.at_level(logging.WARNING, logger="app.platforms.ebay.browser_pool"):
|
||||
pool.start()
|
||||
|
||||
assert any("not available" in r.message for r in caplog.records)
|
||||
|
||||
def test_fetch_fresh_raises_runtime_error_when_playwright_missing(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
|
||||
pool = BrowserPool(size=1)
|
||||
with patch.dict("sys.modules", {"playwright": None, "playwright.sync_api": None}):
|
||||
with pytest.raises(RuntimeError, match="Playwright not installed"):
|
||||
pool._fetch_fresh("https://www.ebay.com/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _replenish_slot helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReplenishSlot:
|
||||
def test_replenish_closes_old_context_and_opens_new(self):
|
||||
from app.platforms.ebay.browser_pool import _replenish_slot, _PooledBrowser
|
||||
|
||||
old_ctx = MagicMock()
|
||||
new_ctx = MagicMock()
|
||||
browser = MagicMock()
|
||||
browser.new_context.return_value = new_ctx
|
||||
|
||||
slot = _PooledBrowser(
|
||||
xvfb=MagicMock(), pw=MagicMock(), browser=browser,
|
||||
ctx=old_ctx, display_num=101, last_used_ts=time.time() - 10,
|
||||
)
|
||||
|
||||
result = _replenish_slot(slot)
|
||||
|
||||
old_ctx.close.assert_called_once()
|
||||
browser.new_context.assert_called_once()
|
||||
assert result.ctx is new_ctx
|
||||
assert result.browser is browser
|
||||
assert result.xvfb is slot.xvfb
|
||||
assert result.last_used_ts > slot.last_used_ts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _close_slot helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCloseSlot:
|
||||
def test_close_slot_closes_all_components(self):
|
||||
from app.platforms.ebay.browser_pool import _close_slot, _PooledBrowser
|
||||
|
||||
xvfb = MagicMock(spec=subprocess.Popen)
|
||||
pw = MagicMock()
|
||||
browser = MagicMock()
|
||||
ctx = MagicMock()
|
||||
|
||||
slot = _PooledBrowser(
|
||||
xvfb=xvfb, pw=pw, browser=browser, ctx=ctx,
|
||||
display_num=102, last_used_ts=time.time(),
|
||||
)
|
||||
_close_slot(slot)
|
||||
|
||||
ctx.close.assert_called_once()
|
||||
browser.close.assert_called_once()
|
||||
pw.stop.assert_called_once()
|
||||
xvfb.terminate.assert_called_once()
|
||||
xvfb.wait.assert_called_once()
|
||||
|
||||
def test_close_slot_ignores_exceptions(self):
|
||||
from app.platforms.ebay.browser_pool import _close_slot, _PooledBrowser
|
||||
|
||||
xvfb = MagicMock(spec=subprocess.Popen)
|
||||
xvfb.terminate.side_effect = OSError("already dead")
|
||||
xvfb.wait.side_effect = OSError("already dead")
|
||||
pw = MagicMock()
|
||||
pw.stop.side_effect = RuntimeError("stopped")
|
||||
browser = MagicMock()
|
||||
browser.close.side_effect = RuntimeError("gone")
|
||||
ctx = MagicMock()
|
||||
ctx.close.side_effect = RuntimeError("gone")
|
||||
|
||||
slot = _PooledBrowser(
|
||||
xvfb=xvfb, pw=pw, browser=browser, ctx=ctx,
|
||||
display_num=103, last_used_ts=time.time(),
|
||||
)
|
||||
_close_slot(slot) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scraper integration — _fetch_url uses pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScraperUsesPool:
|
||||
def test_fetch_url_delegates_to_pool(self):
|
||||
from app.platforms.ebay.browser_pool import BrowserPool
|
||||
from app.platforms.ebay.scraper import ScrapedEbayAdapter
|
||||
from app.db.store import Store
|
||||
|
||||
store = MagicMock(spec=Store)
|
||||
adapter = ScrapedEbayAdapter(store, delay=0)
|
||||
|
||||
fake_pool = MagicMock(spec=BrowserPool)
|
||||
fake_pool.fetch_html.return_value = "<html>pooled</html>"
|
||||
|
||||
with patch("app.platforms.ebay.browser_pool.get_pool", return_value=fake_pool):
|
||||
import app.platforms.ebay.scraper as scraper_mod
|
||||
scraper_mod._html_cache.clear()
|
||||
html = adapter._fetch_url("https://www.ebay.com/sch/i.html?_nkw=test")
|
||||
|
||||
assert html == "<html>pooled</html>"
|
||||
fake_pool.fetch_html.assert_called_once_with(
|
||||
"https://www.ebay.com/sch/i.html?_nkw=test", delay=0
|
||||
)
|
||||
|
||||
def test_fetch_url_uses_cache_before_pool(self):
|
||||
from app.platforms.ebay.scraper import ScrapedEbayAdapter, _html_cache, _HTML_CACHE_TTL
|
||||
from app.db.store import Store
|
||||
|
||||
store = MagicMock(spec=Store)
|
||||
adapter = ScrapedEbayAdapter(store, delay=0)
|
||||
|
||||
url = "https://www.ebay.com/sch/i.html?_nkw=cached"
|
||||
_html_cache[url] = ("<html>cached</html>", time.time() + _HTML_CACHE_TTL)
|
||||
|
||||
fake_pool = MagicMock()
|
||||
with patch("app.platforms.ebay.browser_pool.get_pool", return_value=fake_pool):
|
||||
html = adapter._fetch_url(url)
|
||||
|
||||
assert html == "<html>cached</html>"
|
||||
fake_pool.fetch_html.assert_not_called()
|
||||
_html_cache.pop(url, None)
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from api.main import _extract_ebay_item_id
|
||||
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
||||
|
||||
|
||||
|
|
@ -57,48 +56,3 @@ def test_normalise_seller_maps_fields():
|
|||
assert seller.feedback_count == 300
|
||||
assert seller.feedback_ratio == pytest.approx(0.991, abs=0.001)
|
||||
assert seller.account_age_days > 0
|
||||
|
||||
|
||||
# ── _extract_ebay_item_id ─────────────────────────────────────────────────────
|
||||
|
||||
class TestExtractEbayItemId:
|
||||
"""Unit tests for the URL-to-item-ID normaliser."""
|
||||
|
||||
def test_itm_url_with_title_slug(self):
|
||||
url = "https://www.ebay.com/itm/Sony-WH-1000XM5-Headphones/123456789012"
|
||||
assert _extract_ebay_item_id(url) == "123456789012"
|
||||
|
||||
def test_itm_url_without_title_slug(self):
|
||||
url = "https://www.ebay.com/itm/123456789012"
|
||||
assert _extract_ebay_item_id(url) == "123456789012"
|
||||
|
||||
def test_itm_url_no_www(self):
|
||||
url = "https://ebay.com/itm/123456789012"
|
||||
assert _extract_ebay_item_id(url) == "123456789012"
|
||||
|
||||
def test_itm_url_with_query_params(self):
|
||||
url = "https://www.ebay.com/itm/123456789012?hash=item1234abcd"
|
||||
assert _extract_ebay_item_id(url) == "123456789012"
|
||||
|
||||
def test_pay_ebay_rxo_with_itemId_query_param(self):
|
||||
url = "https://pay.ebay.com/rxo?action=view&sessionid=abc123&itemId=123456789012"
|
||||
assert _extract_ebay_item_id(url) == "123456789012"
|
||||
|
||||
def test_pay_ebay_rxo_path_with_itemId(self):
|
||||
url = "https://pay.ebay.com/rxo/view?itemId=123456789012"
|
||||
assert _extract_ebay_item_id(url) == "123456789012"
|
||||
|
||||
def test_non_ebay_url_returns_none(self):
|
||||
assert _extract_ebay_item_id("https://amazon.com/dp/B08N5WRWNW") is None
|
||||
|
||||
def test_plain_keyword_returns_none(self):
|
||||
assert _extract_ebay_item_id("rtx 4090 gpu") is None
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
assert _extract_ebay_item_id("") is None
|
||||
|
||||
def test_ebay_url_no_item_id_returns_none(self):
|
||||
assert _extract_ebay_item_id("https://www.ebay.com/sch/i.html?_nkw=gpu") is None
|
||||
|
||||
def test_pay_ebay_no_item_id_returns_none(self):
|
||||
assert _extract_ebay_item_id("https://pay.ebay.com/rxo?action=view&sessionid=abc") is None
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
"""Tests for GET /api/search/async (fire-and-forget search + SSE streaming).
|
||||
|
||||
Verifies:
|
||||
- Returns HTTP 202 with session_id and status: "queued"
|
||||
- session_id is registered in _update_queues immediately
|
||||
- Actual scraping is not performed (mocked out)
|
||||
- Empty query path returns a completed session with done event
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""TestClient with a fresh tmp DB. Must set SNIPE_DB *before* importing app."""
|
||||
os.environ["SNIPE_DB"] = str(tmp_path / "snipe.db")
|
||||
from api.main import app
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _make_mock_listing():
|
||||
"""Return a minimal mock listing object that satisfies the search pipeline."""
|
||||
m = MagicMock()
|
||||
m.platform_listing_id = "123456789"
|
||||
m.seller_platform_id = "test_seller"
|
||||
m.title = "Test GPU"
|
||||
m.price = 100.0
|
||||
m.currency = "USD"
|
||||
m.condition = "Used"
|
||||
m.url = "https://www.ebay.com/itm/123456789"
|
||||
m.photo_urls = []
|
||||
m.listing_age_days = 5
|
||||
m.buying_format = "fixed_price"
|
||||
m.ends_at = None
|
||||
m.fetched_at = None
|
||||
m.trust_score_id = None
|
||||
m.id = 1
|
||||
m.category_name = None
|
||||
return m
|
||||
|
||||
|
||||
# ── Core contract tests ───────────────────────────────────────────────────────
|
||||
|
||||
def test_async_search_returns_202(client):
|
||||
"""GET /api/search/async?q=... returns HTTP 202 with session_id and status."""
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment"),
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.return_value = []
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
resp = client.get("/api/search/async?q=test+gpu")
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert "session_id" in data
|
||||
assert data["status"] == "queued"
|
||||
assert isinstance(data["session_id"], str)
|
||||
assert len(data["session_id"]) > 0
|
||||
|
||||
|
||||
def test_async_search_registers_session_id(client):
|
||||
"""session_id returned by 202 response must appear in _update_queues immediately."""
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment"),
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.return_value = []
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
resp = client.get("/api/search/async?q=test+gpu")
|
||||
|
||||
assert resp.status_code == 202
|
||||
session_id = resp.json()["session_id"]
|
||||
|
||||
# The queue must be registered so the SSE endpoint can open it.
|
||||
from api.main import _update_queues
|
||||
assert session_id in _update_queues
|
||||
|
||||
|
||||
def test_async_search_empty_query(client):
|
||||
"""Empty query returns 202 with a pre-loaded done sentinel, no scraping needed."""
|
||||
resp = client.get("/api/search/async?q=")
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert data["status"] == "queued"
|
||||
assert "session_id" in data
|
||||
|
||||
from api.main import _update_queues
|
||||
import queue as _queue
|
||||
sid = data["session_id"]
|
||||
assert sid in _update_queues
|
||||
q = _update_queues[sid]
|
||||
# First item should be the empty listings event
|
||||
first = q.get_nowait()
|
||||
assert first is not None
|
||||
assert first["type"] == "listings"
|
||||
assert first["listings"] == []
|
||||
# Second item should be the sentinel
|
||||
sentinel = q.get_nowait()
|
||||
assert sentinel is None
|
||||
|
||||
|
||||
def test_async_search_no_real_chromium(client):
|
||||
"""Async search endpoint must not launch real Chromium in tests.
|
||||
|
||||
Verifies that the background scraper is submitted to the executor but the
|
||||
adapter factory is patched — no real Playwright/Xvfb process is spawned.
|
||||
Uses a broad patch on Store to avoid sqlite3 DB path issues in the thread pool.
|
||||
"""
|
||||
import threading
|
||||
scrape_called = threading.Event()
|
||||
|
||||
def _fake_search(query, filters):
|
||||
scrape_called.set()
|
||||
return []
|
||||
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment"),
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
patch("api.main.Store") as mock_store_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.side_effect = _fake_search
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_listings_staged.return_value = {}
|
||||
mock_store.refresh_seller_categories.return_value = 0
|
||||
mock_store.save_listings.return_value = None
|
||||
mock_store.save_trust_scores.return_value = None
|
||||
mock_store.get_market_comp.return_value = None
|
||||
mock_store.get_seller.return_value = None
|
||||
mock_store.get_user_preference.return_value = None
|
||||
mock_store_cls.return_value = mock_store
|
||||
|
||||
resp = client.get("/api/search/async?q=rtx+3080")
|
||||
|
||||
assert resp.status_code == 202
|
||||
# Give the background worker a moment to run (it's in a thread pool)
|
||||
scrape_called.wait(timeout=5.0)
|
||||
# If we get here without a real Playwright process, the test passes.
|
||||
assert scrape_called.is_set(), "Background search worker never ran"
|
||||
|
||||
|
||||
def test_async_search_query_params_forwarded(client):
|
||||
"""All filter params accepted by /api/search are also accepted here."""
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment"),
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.return_value = []
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
resp = client.get(
|
||||
"/api/search/async"
|
||||
"?q=rtx+3080"
|
||||
"&max_price=400"
|
||||
"&min_price=100"
|
||||
"&pages=2"
|
||||
"&must_include=rtx,3080"
|
||||
"&must_include_mode=all"
|
||||
"&must_exclude=mining"
|
||||
"&category_id=27386"
|
||||
"&adapter=auto"
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
|
||||
|
||||
def test_async_search_session_id_is_uuid(client):
|
||||
"""session_id must be a valid UUID v4 string."""
|
||||
import uuid as _uuid
|
||||
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment"),
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.return_value = []
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
resp = client.get("/api/search/async?q=test")
|
||||
|
||||
assert resp.status_code == 202
|
||||
sid = resp.json()["session_id"]
|
||||
# Should not raise if it's a valid UUID
|
||||
parsed = _uuid.UUID(sid)
|
||||
assert str(parsed) == sid
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
"""Tests for PATCH /api/preferences display.currency validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""TestClient with a patched local DB path.
|
||||
|
||||
api.cloud_session._LOCAL_SNIPE_DB is set at module import time, so we
|
||||
cannot rely on setting SNIPE_DB before import when other tests have already
|
||||
triggered the module load. Patch the module-level variable directly so
|
||||
the session dependency points at our fresh tmp DB for the duration of this
|
||||
fixture.
|
||||
"""
|
||||
db_path = tmp_path / "snipe.db"
|
||||
# Ensure the DB is initialised so the Store can create its tables.
|
||||
import api.cloud_session as _cs
|
||||
from circuitforge_core.db import get_connection, run_migrations
|
||||
conn = get_connection(db_path)
|
||||
run_migrations(conn, Path("app/db/migrations"))
|
||||
conn.close()
|
||||
|
||||
from api.main import app
|
||||
with patch.object(_cs, "_LOCAL_SNIPE_DB", db_path):
|
||||
yield TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_set_display_currency_valid(client):
|
||||
"""Accepted ISO 4217 codes are stored and returned."""
|
||||
for code in ("USD", "GBP", "EUR", "CAD", "AUD", "JPY", "CHF", "MXN", "BRL", "INR"):
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": code})
|
||||
assert resp.status_code == 200, f"Expected 200 for {code}, got {resp.status_code}: {resp.text}"
|
||||
data = resp.json()
|
||||
assert data.get("display", {}).get("currency") == code
|
||||
|
||||
|
||||
def test_set_display_currency_normalises_lowercase(client):
|
||||
"""Lowercase code is accepted and normalised to uppercase."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "eur"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["display"]["currency"] == "EUR"
|
||||
|
||||
|
||||
def test_set_display_currency_unsupported_returns_400(client):
|
||||
"""Unsupported currency code returns 400 with a clear message."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "XYZ"})
|
||||
assert resp.status_code == 400
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "XYZ" in detail
|
||||
assert "Supported" in detail or "supported" in detail
|
||||
|
||||
|
||||
def test_set_display_currency_empty_string_returns_400(client):
|
||||
"""Empty string is not a valid currency code."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": ""})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_set_display_currency_none_returns_400(client):
|
||||
"""None is not a valid currency code."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": None})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_other_preference_paths_unaffected(client):
|
||||
"""Unrelated preference paths still work normally after currency validation added."""
|
||||
resp = client.patch("/api/preferences", json={"path": "affiliate.opt_out", "value": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("affiliate", {}).get("opt_out") is True
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Unit tests for QueryTranslator — LLMRouter and cf-orch backends mocked at boundary."""
|
||||
"""Unit tests for QueryTranslator — LLMRouter mocked at boundary."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
|
@ -73,7 +73,7 @@ def test_parse_response_missing_required_field():
|
|||
_parse_response(raw)
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
# ── QueryTranslator (integration with mocked LLMRouter) ──────────────────────
|
||||
|
||||
from app.platforms.ebay.categories import EbayCategoryCache
|
||||
from circuitforge_core.db import get_connection, run_migrations
|
||||
|
|
@ -88,22 +88,7 @@ def db_with_categories(tmp_path):
|
|||
return conn
|
||||
|
||||
|
||||
_VALID_LLM_RESPONSE = json.dumps({
|
||||
"base_query": "RTX 3080",
|
||||
"must_include_mode": "groups",
|
||||
"must_include": "rtx|geforce, 3080",
|
||||
"must_exclude": "mining,for parts",
|
||||
"max_price": 300.0,
|
||||
"min_price": None,
|
||||
"condition": ["used"],
|
||||
"category_id": "27386",
|
||||
"explanation": "Searching for used RTX 3080 GPUs under $300.",
|
||||
})
|
||||
|
||||
|
||||
# ── Local LLMRouter backend ───────────────────────────────────────────────────
|
||||
|
||||
def _make_local_translator(db_conn, llm_response: str) -> QueryTranslator:
|
||||
def _make_translator(db_conn, llm_response: str) -> QueryTranslator:
|
||||
from app.platforms.ebay.categories import EbayCategoryCache
|
||||
cache = EbayCategoryCache(db_conn)
|
||||
mock_router = MagicMock()
|
||||
|
|
@ -112,7 +97,18 @@ def _make_local_translator(db_conn, llm_response: str) -> QueryTranslator:
|
|||
|
||||
|
||||
def test_translate_returns_search_params(db_with_categories):
|
||||
t = _make_local_translator(db_with_categories, _VALID_LLM_RESPONSE)
|
||||
llm_out = json.dumps({
|
||||
"base_query": "RTX 3080",
|
||||
"must_include_mode": "groups",
|
||||
"must_include": "rtx|geforce, 3080",
|
||||
"must_exclude": "mining,for parts",
|
||||
"max_price": 300.0,
|
||||
"min_price": None,
|
||||
"condition": ["used"],
|
||||
"category_id": "27386",
|
||||
"explanation": "Searching for used RTX 3080 GPUs under $300.",
|
||||
})
|
||||
t = _make_translator(db_with_categories, llm_out)
|
||||
result = t.translate("used RTX 3080 under $300 no mining")
|
||||
assert result.base_query == "RTX 3080"
|
||||
assert result.max_price == 300.0
|
||||
|
|
@ -120,7 +116,18 @@ def test_translate_returns_search_params(db_with_categories):
|
|||
|
||||
def test_translate_injects_category_hints(db_with_categories):
|
||||
"""The system prompt sent to the LLM must contain category_id hints."""
|
||||
t = _make_local_translator(db_with_categories, _VALID_LLM_RESPONSE)
|
||||
llm_out = json.dumps({
|
||||
"base_query": "GPU",
|
||||
"must_include_mode": "all",
|
||||
"must_include": "",
|
||||
"must_exclude": "",
|
||||
"max_price": None,
|
||||
"min_price": None,
|
||||
"condition": [],
|
||||
"category_id": None,
|
||||
"explanation": "Searching for GPUs.",
|
||||
})
|
||||
t = _make_translator(db_with_categories, llm_out)
|
||||
t.translate("GPU")
|
||||
call_args = t._llm_router.complete.call_args
|
||||
system_prompt = call_args.kwargs.get("system") or call_args.args[1]
|
||||
|
|
@ -134,7 +141,7 @@ def test_translate_empty_category_cache_still_works(tmp_path):
|
|||
conn = get_connection(tmp_path / "empty.db")
|
||||
run_migrations(conn, Path("app/db/migrations"))
|
||||
# Do NOT seed bootstrap — empty cache
|
||||
t = _make_local_translator(conn, json.dumps({
|
||||
llm_out = json.dumps({
|
||||
"base_query": "vinyl",
|
||||
"must_include_mode": "all",
|
||||
"must_include": "",
|
||||
|
|
@ -144,7 +151,8 @@ def test_translate_empty_category_cache_still_works(tmp_path):
|
|||
"condition": [],
|
||||
"category_id": None,
|
||||
"explanation": "Searching for vinyl records.",
|
||||
}))
|
||||
})
|
||||
t = _make_translator(conn, llm_out)
|
||||
result = t.translate("vinyl records")
|
||||
assert result.base_query == "vinyl"
|
||||
call_args = t._llm_router.complete.call_args
|
||||
|
|
@ -160,101 +168,3 @@ def test_translate_llm_error_raises_query_translator_error(db_with_categories):
|
|||
t = QueryTranslator(category_cache=cache, llm_router=mock_router)
|
||||
with pytest.raises(QueryTranslatorError, match="LLM backend"):
|
||||
t.translate("used GPU")
|
||||
|
||||
|
||||
# ── cf-orch backend ───────────────────────────────────────────────────────────
|
||||
|
||||
def _make_orch_translator(db_conn) -> QueryTranslator:
|
||||
from app.platforms.ebay.categories import EbayCategoryCache
|
||||
cache = EbayCategoryCache(db_conn)
|
||||
return QueryTranslator(category_cache=cache, cforch_url="http://orch.local:8700")
|
||||
|
||||
|
||||
def _mock_alloc_response() -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"url": "http://cf-text.local:11434",
|
||||
"allocation_id": "alloc-abc123",
|
||||
"node_id": "heimdall",
|
||||
}
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_chat_response(content: str) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"choices": [{"message": {"content": content}}]
|
||||
}
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_delete_response() -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
def test_orch_translate_returns_search_params(db_with_categories):
|
||||
t = _make_orch_translator(db_with_categories)
|
||||
with patch("httpx.post") as mock_post, patch("httpx.delete") as mock_delete:
|
||||
mock_post.side_effect = [
|
||||
_mock_alloc_response(),
|
||||
_mock_chat_response(_VALID_LLM_RESPONSE),
|
||||
]
|
||||
mock_delete.return_value = _mock_delete_response()
|
||||
result = t.translate("used RTX 3080 under $300")
|
||||
assert result.base_query == "RTX 3080"
|
||||
assert result.max_price == 300.0
|
||||
|
||||
|
||||
def test_orch_allocates_with_correct_task_tag(db_with_categories):
|
||||
t = _make_orch_translator(db_with_categories)
|
||||
with patch("httpx.post") as mock_post, patch("httpx.delete"):
|
||||
mock_post.side_effect = [
|
||||
_mock_alloc_response(),
|
||||
_mock_chat_response(_VALID_LLM_RESPONSE),
|
||||
]
|
||||
t.translate("GPU")
|
||||
alloc_call = mock_post.call_args_list[0]
|
||||
assert alloc_call.args[0] == "http://orch.local:8700/api/inference/task"
|
||||
body = alloc_call.kwargs.get("json") or alloc_call.args[1]
|
||||
assert body == {"product": "snipe", "task": "query_translation"}
|
||||
|
||||
|
||||
def test_orch_releases_allocation_after_success(db_with_categories):
|
||||
t = _make_orch_translator(db_with_categories)
|
||||
with patch("httpx.post") as mock_post, patch("httpx.delete") as mock_delete:
|
||||
mock_post.side_effect = [
|
||||
_mock_alloc_response(),
|
||||
_mock_chat_response(_VALID_LLM_RESPONSE),
|
||||
]
|
||||
mock_delete.return_value = _mock_delete_response()
|
||||
t.translate("GPU")
|
||||
mock_delete.assert_called_once()
|
||||
delete_url = mock_delete.call_args.args[0]
|
||||
assert "alloc-abc123" in delete_url
|
||||
|
||||
|
||||
def test_orch_releases_allocation_on_inference_failure(db_with_categories):
|
||||
"""Allocation must be released even when the inference call fails."""
|
||||
t = _make_orch_translator(db_with_categories)
|
||||
with patch("httpx.post") as mock_post, patch("httpx.delete") as mock_delete:
|
||||
mock_post.side_effect = [
|
||||
_mock_alloc_response(),
|
||||
Exception("inference timeout"),
|
||||
]
|
||||
mock_delete.return_value = _mock_delete_response()
|
||||
with pytest.raises(QueryTranslatorError, match="LLM backend"):
|
||||
t.translate("GPU")
|
||||
mock_delete.assert_called_once()
|
||||
|
||||
|
||||
def test_init_requires_at_least_one_backend(tmp_path):
|
||||
from circuitforge_core.db import get_connection, run_migrations
|
||||
conn = get_connection(tmp_path / "test.db")
|
||||
run_migrations(conn, Path("app/db/migrations"))
|
||||
cache = EbayCategoryCache(conn)
|
||||
with pytest.raises(ValueError, match="cforch_url or llm_router"):
|
||||
QueryTranslator(category_cache=cache)
|
||||
|
|
|
|||
|
|
@ -1,402 +0,0 @@
|
|||
"""Tests for the short-TTL search result cache in api/main.py.
|
||||
|
||||
Covers:
|
||||
- _cache_key stability (same inputs → same key)
|
||||
- _cache_key uniqueness (different inputs → different keys)
|
||||
- cache hit path returns early without scraping (async worker)
|
||||
- cache miss path stores result in _search_result_cache
|
||||
- refresh=True bypasses cache read (still writes fresh result)
|
||||
- TTL expiry: expired entries are not returned as hits
|
||||
- _evict_expired_cache removes expired entries
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue as _queue
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _clear_cache():
|
||||
"""Reset module-level cache state between tests."""
|
||||
import api.main as _main
|
||||
_main._search_result_cache.clear()
|
||||
_main._last_eviction_ts = 0.0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_cache():
|
||||
"""Ensure each test starts with an empty cache."""
|
||||
_clear_cache()
|
||||
yield
|
||||
_clear_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""TestClient backed by a fresh tmp DB."""
|
||||
os.environ["SNIPE_DB"] = str(tmp_path / "snipe.db")
|
||||
from api.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _make_mock_listing(listing_id: str = "123456789", seller_id: str = "test_seller"):
|
||||
"""Return a MagicMock listing (for use where asdict() is NOT called on it)."""
|
||||
m = MagicMock()
|
||||
m.platform_listing_id = listing_id
|
||||
m.seller_platform_id = seller_id
|
||||
m.title = "Test GPU"
|
||||
m.price = 100.0
|
||||
m.currency = "USD"
|
||||
m.condition = "Used"
|
||||
m.url = f"https://www.ebay.com/itm/{listing_id}"
|
||||
m.photo_urls = []
|
||||
m.listing_age_days = 5
|
||||
m.buying_format = "fixed_price"
|
||||
m.ends_at = None
|
||||
m.fetched_at = None
|
||||
m.trust_score_id = None
|
||||
m.id = 1
|
||||
m.category_name = None
|
||||
return m
|
||||
|
||||
|
||||
def _make_real_listing(listing_id: str = "123456789", seller_id: str = "test_seller"):
|
||||
"""Return a real Listing dataclass instance (for use where asdict() is called)."""
|
||||
from app.db.models import Listing
|
||||
return Listing(
|
||||
platform="ebay",
|
||||
platform_listing_id=listing_id,
|
||||
title="Test GPU",
|
||||
price=100.0,
|
||||
currency="USD",
|
||||
condition="Used",
|
||||
seller_platform_id=seller_id,
|
||||
url=f"https://www.ebay.com/itm/{listing_id}",
|
||||
photo_urls=[],
|
||||
listing_age_days=5,
|
||||
buying_format="fixed_price",
|
||||
id=None,
|
||||
)
|
||||
|
||||
|
||||
# ── _cache_key unit tests ─────────────────────────────────────────────────────
|
||||
|
||||
def test_cache_key_stable_for_same_inputs():
|
||||
"""The same parameter set always produces the same key."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("rtx 3080", 400.0, 100.0, 2, "rtx,3080", "all", "mining", "27386")
|
||||
k2 = _cache_key("rtx 3080", 400.0, 100.0, 2, "rtx,3080", "all", "mining", "27386")
|
||||
assert k1 == k2
|
||||
|
||||
|
||||
def test_cache_key_case_normalised():
|
||||
"""Query is normalised to lower-case + stripped before hashing."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("RTX 3080", None, None, 1, "", "all", "", "")
|
||||
k2 = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
||||
assert k1 == k2
|
||||
|
||||
|
||||
def test_cache_key_differs_on_query_change():
|
||||
"""Different query strings must produce different keys."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
||||
k2 = _cache_key("gtx 1080", None, None, 1, "", "all", "", "")
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_cache_key_differs_on_price_filter():
|
||||
"""Different max_price must produce a different key."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("gpu", 400.0, None, 1, "", "all", "", "")
|
||||
k2 = _cache_key("gpu", 500.0, None, 1, "", "all", "", "")
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_cache_key_differs_on_min_price():
|
||||
"""Different min_price must produce a different key."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("gpu", None, 50.0, 1, "", "all", "", "")
|
||||
k2 = _cache_key("gpu", None, 100.0, 1, "", "all", "", "")
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_cache_key_differs_on_pages():
|
||||
"""Different page count must produce a different key."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("gpu", None, None, 1, "", "all", "", "")
|
||||
k2 = _cache_key("gpu", None, None, 2, "", "all", "", "")
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_cache_key_differs_on_must_include():
|
||||
"""Different must_include terms must produce a different key."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("gpu", None, None, 1, "rtx", "all", "", "")
|
||||
k2 = _cache_key("gpu", None, None, 1, "gtx", "all", "", "")
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_cache_key_differs_on_must_exclude():
|
||||
"""Different must_exclude terms must produce a different key."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("gpu", None, None, 1, "", "all", "mining", "")
|
||||
k2 = _cache_key("gpu", None, None, 1, "", "all", "defective", "")
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_cache_key_differs_on_category_id():
|
||||
"""Different category_id must produce a different key."""
|
||||
from api.main import _cache_key
|
||||
k1 = _cache_key("gpu", None, None, 1, "", "all", "", "27386")
|
||||
k2 = _cache_key("gpu", None, None, 1, "", "all", "", "12345")
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_cache_key_is_16_chars():
|
||||
"""Key must be exactly 16 hex characters."""
|
||||
from api.main import _cache_key
|
||||
k = _cache_key("gpu", None, None, 1, "", "all", "", "")
|
||||
assert len(k) == 16
|
||||
assert all(c in "0123456789abcdef" for c in k)
|
||||
|
||||
|
||||
# ── TTL / eviction unit tests ─────────────────────────────────────────────────
|
||||
|
||||
def test_expired_entry_is_not_returned_as_hit():
|
||||
"""An entry past its TTL must not be treated as a cache hit."""
|
||||
import api.main as _main
|
||||
from api.main import _cache_key
|
||||
|
||||
key = _cache_key("gpu", None, None, 1, "", "all", "", "")
|
||||
# Write an already-expired entry.
|
||||
_main._search_result_cache[key] = (
|
||||
{"listings": [], "market_price": None},
|
||||
time.time() - 1.0, # expired 1 second ago
|
||||
)
|
||||
|
||||
cached = _main._search_result_cache.get(key)
|
||||
assert cached is not None
|
||||
payload, expiry = cached
|
||||
# Simulate the hit-check used in main.py
|
||||
assert expiry <= time.time(), "Entry should be expired"
|
||||
|
||||
|
||||
def test_evict_expired_cache_removes_stale_entries():
|
||||
"""_evict_expired_cache must remove entries whose expiry has passed."""
|
||||
import api.main as _main
|
||||
from api.main import _cache_key, _evict_expired_cache
|
||||
|
||||
key_expired = _cache_key("old query", None, None, 1, "", "all", "", "")
|
||||
key_valid = _cache_key("new query", None, None, 1, "", "all", "", "")
|
||||
|
||||
_main._search_result_cache[key_expired] = (
|
||||
{"listings": [], "market_price": None},
|
||||
time.time() - 10.0, # already expired
|
||||
)
|
||||
_main._search_result_cache[key_valid] = (
|
||||
{"listings": [], "market_price": 99.0},
|
||||
time.time() + 300.0, # valid for 5 min
|
||||
)
|
||||
|
||||
# Reset throttle so eviction runs immediately.
|
||||
_main._last_eviction_ts = 0.0
|
||||
_evict_expired_cache()
|
||||
|
||||
assert key_expired not in _main._search_result_cache
|
||||
assert key_valid in _main._search_result_cache
|
||||
|
||||
|
||||
def test_evict_is_rate_limited():
|
||||
"""_evict_expired_cache should skip eviction if called within 60 s."""
|
||||
import api.main as _main
|
||||
from api.main import _cache_key, _evict_expired_cache
|
||||
|
||||
key_expired = _cache_key("stale", None, None, 1, "", "all", "", "")
|
||||
_main._search_result_cache[key_expired] = (
|
||||
{"listings": [], "market_price": None},
|
||||
time.time() - 5.0,
|
||||
)
|
||||
|
||||
# Pretend eviction just ran.
|
||||
_main._last_eviction_ts = time.time()
|
||||
_evict_expired_cache()
|
||||
|
||||
# Entry should still be present because eviction was throttled.
|
||||
assert key_expired in _main._search_result_cache
|
||||
|
||||
|
||||
# ── Integration tests — async endpoint cache hit ──────────────────────────────
|
||||
|
||||
def test_async_cache_hit_skips_scraper(client, tmp_path):
|
||||
"""On a warm cache hit the scraper adapter must not be called."""
|
||||
import threading
|
||||
import api.main as _main
|
||||
from api.main import _cache_key
|
||||
|
||||
# Pre-seed a valid cache entry.
|
||||
key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
||||
_main._search_result_cache[key] = (
|
||||
{"listings": [], "market_price": 250.0},
|
||||
time.time() + 300.0,
|
||||
)
|
||||
|
||||
scraper_called = threading.Event()
|
||||
|
||||
def _fake_search(query, filters):
|
||||
scraper_called.set()
|
||||
return []
|
||||
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment"),
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
patch("api.main.Store") as mock_store_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.side_effect = _fake_search
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_listings_staged.return_value = {}
|
||||
mock_store.refresh_seller_categories.return_value = 0
|
||||
mock_store.save_listings.return_value = None
|
||||
mock_store.save_trust_scores.return_value = None
|
||||
mock_store.get_market_comp.return_value = None
|
||||
mock_store.get_seller.return_value = None
|
||||
mock_store.get_user_preference.return_value = None
|
||||
mock_store_cls.return_value = mock_store
|
||||
|
||||
resp = client.get("/api/search/async?q=rtx+3080")
|
||||
assert resp.status_code == 202
|
||||
|
||||
# Give the background worker a moment to run.
|
||||
scraper_called.wait(timeout=3.0)
|
||||
|
||||
# Scraper must NOT have been called on a cache hit.
|
||||
assert not scraper_called.is_set(), "Scraper was called despite a warm cache hit"
|
||||
|
||||
|
||||
def test_async_cache_miss_stores_result(client, tmp_path):
|
||||
"""After a cache miss the result must be stored in _search_result_cache."""
|
||||
import threading
|
||||
import api.main as _main
|
||||
from api.main import _cache_key
|
||||
|
||||
search_done = threading.Event()
|
||||
real_listing = _make_real_listing()
|
||||
|
||||
def _fake_search(query, filters):
|
||||
return [real_listing]
|
||||
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment") as mock_enrich,
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
patch("api.main.Store") as mock_store_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.side_effect = _fake_search
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_listings_staged.return_value = {
|
||||
real_listing.platform_listing_id: real_listing
|
||||
}
|
||||
mock_store.refresh_seller_categories.return_value = 0
|
||||
mock_store.save_listings.return_value = None
|
||||
mock_store.save_trust_scores.return_value = None
|
||||
mock_store.get_market_comp.return_value = None
|
||||
mock_store.get_seller.return_value = None
|
||||
mock_store.get_user_preference.return_value = None
|
||||
mock_store_cls.return_value = mock_store
|
||||
|
||||
def _enrich_side_effect(*args, **kwargs):
|
||||
search_done.set()
|
||||
|
||||
mock_enrich.side_effect = _enrich_side_effect
|
||||
|
||||
resp = client.get("/api/search/async?q=rtx+3080")
|
||||
assert resp.status_code == 202
|
||||
|
||||
# Wait until the background worker reaches _trigger_scraper_enrichment.
|
||||
search_done.wait(timeout=5.0)
|
||||
|
||||
assert search_done.is_set(), "Background search worker never completed"
|
||||
|
||||
key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
||||
assert key in _main._search_result_cache, "Result was not stored in cache after miss"
|
||||
payload, expiry = _main._search_result_cache[key]
|
||||
assert expiry > time.time(), "Cache entry has already expired"
|
||||
assert "listings" in payload
|
||||
|
||||
|
||||
# ── Integration tests — async endpoint refresh=True ──────────────────────────
|
||||
|
||||
def test_async_refresh_bypasses_cache_read(client, tmp_path):
|
||||
"""refresh=True must bypass cache read and invoke the scraper."""
|
||||
import threading
|
||||
import api.main as _main
|
||||
from api.main import _cache_key
|
||||
|
||||
# Seed a valid cache entry so we can confirm it is bypassed.
|
||||
key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
||||
_main._search_result_cache[key] = (
|
||||
{"listings": [], "market_price": 100.0},
|
||||
time.time() + 300.0,
|
||||
)
|
||||
|
||||
scraper_called = threading.Event()
|
||||
|
||||
def _fake_search(query, filters):
|
||||
scraper_called.set()
|
||||
return []
|
||||
|
||||
with (
|
||||
patch("api.main._make_adapter") as mock_adapter_factory,
|
||||
patch("api.main._trigger_scraper_enrichment"),
|
||||
patch("api.main.TrustScorer") as mock_scorer_cls,
|
||||
patch("api.main.Store") as mock_store_cls,
|
||||
):
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.search.side_effect = _fake_search
|
||||
mock_adapter.get_completed_sales.return_value = None
|
||||
mock_adapter_factory.return_value = mock_adapter
|
||||
|
||||
mock_scorer = MagicMock()
|
||||
mock_scorer.score_batch.return_value = []
|
||||
mock_scorer_cls.return_value = mock_scorer
|
||||
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_listings_staged.return_value = {}
|
||||
mock_store.refresh_seller_categories.return_value = 0
|
||||
mock_store.save_listings.return_value = None
|
||||
mock_store.save_trust_scores.return_value = None
|
||||
mock_store.get_market_comp.return_value = None
|
||||
mock_store.get_seller.return_value = None
|
||||
mock_store.get_user_preference.return_value = None
|
||||
mock_store_cls.return_value = mock_store
|
||||
|
||||
resp = client.get("/api/search/async?q=rtx+3080&refresh=true")
|
||||
assert resp.status_code == 202
|
||||
|
||||
scraper_called.wait(timeout=5.0)
|
||||
|
||||
assert scraper_called.is_set(), "Scraper was not called even though refresh=True"
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
"""Tests for the background monitor: should_alert logic, store alert methods, and run_monitor_search."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tasks.monitor import _AUCTION_ALERT_WINDOW_HOURS, should_alert
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_alert — pure function, no I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShouldAlert:
|
||||
def test_bin_above_threshold_alerts(self):
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="fixed_price",
|
||||
min_trust_score=60,
|
||||
) is True
|
||||
|
||||
def test_bin_below_threshold_no_alert(self):
|
||||
assert should_alert(
|
||||
trust_score=55, score_is_partial=False,
|
||||
price=100.0, buying_format="fixed_price",
|
||||
min_trust_score=60,
|
||||
) is False
|
||||
|
||||
def test_partial_score_applies_buffer(self):
|
||||
# Score 65 with min 60 passes normally but fails with the +10 partial buffer.
|
||||
assert should_alert(
|
||||
trust_score=65, score_is_partial=True,
|
||||
price=100.0, buying_format="fixed_price",
|
||||
min_trust_score=60,
|
||||
) is False
|
||||
|
||||
def test_partial_score_above_buffered_threshold_alerts(self):
|
||||
assert should_alert(
|
||||
trust_score=75, score_is_partial=True,
|
||||
price=100.0, buying_format="fixed_price",
|
||||
min_trust_score=60,
|
||||
) is True
|
||||
|
||||
def test_best_offer_treated_like_bin(self):
|
||||
assert should_alert(
|
||||
trust_score=80, score_is_partial=False,
|
||||
price=200.0, buying_format="best_offer",
|
||||
min_trust_score=60,
|
||||
) is True
|
||||
|
||||
def test_auction_within_window_alerts(self):
|
||||
soon = (datetime.now(timezone.utc) + timedelta(hours=12)).isoformat()
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="auction",
|
||||
min_trust_score=60, ends_at=soon,
|
||||
) is True
|
||||
|
||||
def test_auction_outside_window_no_alert(self):
|
||||
far = (datetime.now(timezone.utc) + timedelta(hours=48)).isoformat()
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="auction",
|
||||
min_trust_score=60, ends_at=far,
|
||||
) is False
|
||||
|
||||
def test_auction_no_ends_at_alerts_anyway(self):
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="auction",
|
||||
min_trust_score=60, ends_at=None,
|
||||
) is True
|
||||
|
||||
def test_auction_bad_ends_at_alerts_anyway(self):
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="auction",
|
||||
min_trust_score=60, ends_at="not-a-date",
|
||||
) is True
|
||||
|
||||
def test_auction_expired_no_alert(self):
|
||||
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="auction",
|
||||
min_trust_score=60, ends_at=past,
|
||||
) is False
|
||||
|
||||
def test_unknown_format_alerts(self):
|
||||
# Fail-open: unknown buying_format should not silently suppress.
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="mystery_format",
|
||||
min_trust_score=60,
|
||||
) is True
|
||||
|
||||
def test_score_exactly_at_threshold_passes(self):
|
||||
assert should_alert(
|
||||
trust_score=60, score_is_partial=False,
|
||||
price=100.0, buying_format="fixed_price",
|
||||
min_trust_score=60,
|
||||
) is True
|
||||
|
||||
def test_auction_exactly_at_window_boundary_alerts(self):
|
||||
boundary = (datetime.now(timezone.utc) + timedelta(hours=_AUCTION_ALERT_WINDOW_HOURS - 0.1)).isoformat()
|
||||
assert should_alert(
|
||||
trust_score=70, score_is_partial=False,
|
||||
price=100.0, buying_format="auction",
|
||||
min_trust_score=60, ends_at=boundary,
|
||||
) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store alert methods — integration against real SQLite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_monitor_db(path: Path) -> None:
|
||||
conn = sqlite3.connect(path)
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'ebay',
|
||||
filters_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_run_at TEXT,
|
||||
monitor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
poll_interval_min INTEGER NOT NULL DEFAULT 60,
|
||||
min_trust_score INTEGER NOT NULL DEFAULT 60,
|
||||
last_checked_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS watch_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
saved_search_id INTEGER NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE,
|
||||
platform_listing_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
trust_score INTEGER NOT NULL,
|
||||
url TEXT,
|
||||
first_alerted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
dismissed_at TEXT,
|
||||
UNIQUE(saved_search_id, platform_listing_id)
|
||||
);
|
||||
INSERT INTO saved_searches (name, query, monitor_enabled) VALUES ('RTX 4090', 'rtx 4090', 1);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monitor_db(tmp_path: Path) -> Path:
|
||||
db = tmp_path / "snipe.db"
|
||||
_create_monitor_db(db)
|
||||
return db
|
||||
|
||||
|
||||
class TestStoreAlertMethods:
|
||||
def test_upsert_alert_new(self, monitor_db: Path):
|
||||
from app.db.models import WatchAlert
|
||||
from app.db.store import Store
|
||||
|
||||
store = Store(monitor_db)
|
||||
alert = WatchAlert(
|
||||
saved_search_id=1, platform_listing_id="ebay-001",
|
||||
title="RTX 4090", price=750.0, trust_score=72, currency="USD",
|
||||
url="https://ebay.com/itm/001",
|
||||
)
|
||||
alert_id, is_new = store.upsert_alert(alert)
|
||||
assert is_new is True
|
||||
assert alert_id > 0
|
||||
|
||||
def test_upsert_alert_dedup(self, monitor_db: Path):
|
||||
from app.db.models import WatchAlert
|
||||
from app.db.store import Store
|
||||
|
||||
store = Store(monitor_db)
|
||||
alert = WatchAlert(
|
||||
saved_search_id=1, platform_listing_id="ebay-002",
|
||||
title="RTX 4090 FE", price=800.0, trust_score=68,
|
||||
)
|
||||
id1, new1 = store.upsert_alert(alert)
|
||||
id2, new2 = store.upsert_alert(alert)
|
||||
assert id1 == id2
|
||||
assert new1 is True
|
||||
assert new2 is False
|
||||
|
||||
def test_list_alerts_returns_undismissed(self, monitor_db: Path):
|
||||
from app.db.models import WatchAlert
|
||||
from app.db.store import Store
|
||||
|
||||
store = Store(monitor_db)
|
||||
alert = WatchAlert(
|
||||
saved_search_id=1, platform_listing_id="ebay-003",
|
||||
title="Test listing", price=500.0, trust_score=75,
|
||||
)
|
||||
store.upsert_alert(alert)
|
||||
alerts = store.list_alerts(include_dismissed=False)
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0].platform_listing_id == "ebay-003"
|
||||
|
||||
def test_count_undismissed_alerts(self, monitor_db: Path):
|
||||
from app.db.models import WatchAlert
|
||||
from app.db.store import Store
|
||||
|
||||
store = Store(monitor_db)
|
||||
for i in range(3):
|
||||
store.upsert_alert(WatchAlert(
|
||||
saved_search_id=1, platform_listing_id=f"ebay-{i:03d}",
|
||||
title=f"Listing {i}", price=float(100 + i), trust_score=70,
|
||||
))
|
||||
assert store.count_undismissed_alerts() == 3
|
||||
|
||||
def test_dismiss_alert(self, monitor_db: Path):
|
||||
from app.db.models import WatchAlert
|
||||
from app.db.store import Store
|
||||
|
||||
store = Store(monitor_db)
|
||||
alert = WatchAlert(
|
||||
saved_search_id=1, platform_listing_id="ebay-dismiss",
|
||||
title="To dismiss", price=400.0, trust_score=65,
|
||||
)
|
||||
alert_id, _ = store.upsert_alert(alert)
|
||||
store.dismiss_alert(alert_id)
|
||||
alerts = store.list_alerts(include_dismissed=False)
|
||||
assert all(a.id != alert_id for a in alerts)
|
||||
|
||||
def test_dismiss_all_alerts(self, monitor_db: Path):
|
||||
from app.db.models import WatchAlert
|
||||
from app.db.store import Store
|
||||
|
||||
store = Store(monitor_db)
|
||||
for i in range(3):
|
||||
store.upsert_alert(WatchAlert(
|
||||
saved_search_id=1, platform_listing_id=f"all-{i}",
|
||||
title=f"All {i}", price=float(100 * i), trust_score=70,
|
||||
))
|
||||
count = store.dismiss_all_alerts()
|
||||
assert count == 3
|
||||
assert store.count_undismissed_alerts() == 0
|
||||
|
||||
def test_mark_search_checked_updates_timestamp(self, monitor_db: Path):
|
||||
from app.db.store import Store
|
||||
|
||||
store = Store(monitor_db)
|
||||
store.mark_search_checked(1)
|
||||
searches = store.list_monitored_searches()
|
||||
assert searches[0].last_checked_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_monitor_search — mocked adapter + trust aggregator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunMonitorSearch:
|
||||
def test_new_qualifying_listing_creates_alert(self, monitor_db: Path):
|
||||
from app.db.models import Listing, SavedSearch, TrustScore
|
||||
from app.db.store import Store
|
||||
from app.tasks.monitor import run_monitor_search
|
||||
|
||||
search = SavedSearch(
|
||||
id=1, name="RTX 4090", query="rtx 4090",
|
||||
platform="ebay", monitor_enabled=True,
|
||||
min_trust_score=60,
|
||||
)
|
||||
mock_listing = Listing(
|
||||
platform="ebay", platform_listing_id="ebay-new",
|
||||
title="ASUS RTX 4090", price=750.0, currency="USD",
|
||||
condition="used", url="https://ebay.com/itm/new",
|
||||
buying_format="fixed_price", seller_platform_id="seller123",
|
||||
)
|
||||
mock_trust = TrustScore(
|
||||
listing_id=0, composite_score=72, score_is_partial=False,
|
||||
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
|
||||
price_vs_market_score=0, category_history_score=0,
|
||||
)
|
||||
|
||||
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
|
||||
patch("app.trust.TrustScorer") as MockAgg:
|
||||
MockAdapter.return_value.search.return_value = [mock_listing]
|
||||
MockAgg.return_value.score_batch.return_value = [mock_trust]
|
||||
|
||||
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||
|
||||
assert count == 1
|
||||
alerts = Store(monitor_db).list_alerts()
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0].platform_listing_id == "ebay-new"
|
||||
|
||||
def test_below_threshold_listing_not_alerted(self, monitor_db: Path):
|
||||
from app.db.models import Listing, SavedSearch, TrustScore
|
||||
from app.tasks.monitor import run_monitor_search
|
||||
|
||||
search = SavedSearch(
|
||||
id=1, name="RTX 4090", query="rtx 4090",
|
||||
platform="ebay", monitor_enabled=True,
|
||||
min_trust_score=70,
|
||||
)
|
||||
mock_listing = Listing(
|
||||
platform="ebay", platform_listing_id="ebay-low",
|
||||
title="Sketchy RTX 4090", price=500.0, currency="USD",
|
||||
condition="used", url="https://ebay.com/itm/low",
|
||||
buying_format="fixed_price", seller_platform_id="s1",
|
||||
)
|
||||
mock_trust = TrustScore(
|
||||
listing_id=0, composite_score=55, score_is_partial=False,
|
||||
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
|
||||
price_vs_market_score=0, category_history_score=0,
|
||||
)
|
||||
|
||||
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
|
||||
patch("app.trust.TrustScorer") as MockAgg:
|
||||
MockAdapter.return_value.search.return_value = [mock_listing]
|
||||
MockAgg.return_value.score_batch.return_value = [mock_trust]
|
||||
|
||||
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||
|
||||
assert count == 0
|
||||
|
||||
def test_duplicate_listing_not_double_alerted(self, monitor_db: Path):
|
||||
from app.db.models import Listing, SavedSearch, TrustScore
|
||||
from app.tasks.monitor import run_monitor_search
|
||||
|
||||
search = SavedSearch(
|
||||
id=1, name="RTX 4090", query="rtx 4090",
|
||||
platform="ebay", monitor_enabled=True, min_trust_score=60,
|
||||
)
|
||||
mock_listing = Listing(
|
||||
platform="ebay", platform_listing_id="ebay-dupe",
|
||||
title="RTX 4090", price=700.0, currency="USD",
|
||||
condition="used", url="https://ebay.com/itm/dupe",
|
||||
buying_format="fixed_price", seller_platform_id="s1",
|
||||
)
|
||||
mock_trust = TrustScore(
|
||||
listing_id=0, composite_score=75, score_is_partial=False,
|
||||
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
|
||||
price_vs_market_score=0, category_history_score=0,
|
||||
)
|
||||
|
||||
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
|
||||
patch("app.trust.TrustScorer") as MockAgg:
|
||||
MockAdapter.return_value.search.return_value = [mock_listing]
|
||||
MockAgg.return_value.score_batch.return_value = [mock_trust]
|
||||
|
||||
count1 = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||
count2 = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||
|
||||
assert count1 == 1
|
||||
assert count2 == 0 # deduped by UNIQUE constraint
|
||||
|
||||
def test_adapter_failure_returns_zero(self, monitor_db: Path):
|
||||
from app.db.models import SavedSearch
|
||||
from app.tasks.monitor import run_monitor_search
|
||||
|
||||
search = SavedSearch(
|
||||
id=1, name="RTX 4090", query="rtx 4090",
|
||||
platform="ebay", monitor_enabled=True, min_trust_score=60,
|
||||
)
|
||||
|
||||
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter:
|
||||
MockAdapter.return_value.search.side_effect = RuntimeError("eBay down")
|
||||
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||
|
||||
assert count == 0
|
||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -47,19 +47,6 @@ def tmp_db(tmp_path: Path) -> Path:
|
|||
return db
|
||||
|
||||
|
||||
_VISION_JSON = json.dumps({
|
||||
"is_stock_photo": False,
|
||||
"visible_damage": False,
|
||||
"authenticity_signal": "genuine_product_photo",
|
||||
"confidence": "high",
|
||||
})
|
||||
|
||||
_PARAMS = json.dumps({
|
||||
"photo_url": "https://example.com/photo.jpg",
|
||||
"listing_title": "Used iPhone 13",
|
||||
})
|
||||
|
||||
|
||||
def test_llm_task_types_defined():
|
||||
assert "trust_photo_analysis" in LLM_TASK_TYPES
|
||||
|
||||
|
|
@ -88,17 +75,29 @@ def test_insert_task_dedup(tmp_db: Path):
|
|||
assert new2 is False
|
||||
|
||||
|
||||
# ── Local LLMRouter path ──────────────────────────────────────────────────────
|
||||
def test_run_task_photo_analysis_success(tmp_db: Path):
|
||||
"""Vision analysis result is written to trust_scores.photo_analysis_json."""
|
||||
params = json.dumps({
|
||||
"listing_id": 1,
|
||||
"photo_url": "https://example.com/photo.jpg",
|
||||
"listing_title": "Used iPhone 13",
|
||||
})
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=params)
|
||||
|
||||
def test_run_task_photo_analysis_local_success(tmp_db: Path):
|
||||
"""Local path: vision result is written to trust_scores.photo_analysis_json."""
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
||||
vision_result = {
|
||||
"is_stock_photo": False,
|
||||
"visible_damage": False,
|
||||
"authenticity_signal": "genuine_product_photo",
|
||||
"confidence": "high",
|
||||
}
|
||||
|
||||
with patch("app.tasks.runner.requests") as mock_req, \
|
||||
patch("app.tasks.runner._assess_via_local_llm", return_value=_VISION_JSON):
|
||||
patch("app.tasks.runner.LLMRouter") as MockRouter:
|
||||
mock_req.get.return_value.content = b"fake_image_bytes"
|
||||
mock_req.get.return_value.raise_for_status = lambda: None
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, _PARAMS)
|
||||
instance = MockRouter.return_value
|
||||
instance.complete.return_value = json.dumps(vision_result)
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, params)
|
||||
|
||||
conn = sqlite3.connect(tmp_db)
|
||||
score_row = conn.execute(
|
||||
|
|
@ -111,16 +110,20 @@ def test_run_task_photo_analysis_local_success(tmp_db: Path):
|
|||
assert task_row[0] == "completed"
|
||||
parsed = json.loads(score_row[0])
|
||||
assert parsed["is_stock_photo"] is False
|
||||
assert parsed["confidence"] == "high"
|
||||
|
||||
|
||||
def test_run_task_photo_fetch_failure_marks_failed(tmp_db: Path):
|
||||
"""If photo download fails, task is marked failed without crashing."""
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
||||
params = json.dumps({
|
||||
"listing_id": 1,
|
||||
"photo_url": "https://example.com/bad.jpg",
|
||||
"listing_title": "Laptop",
|
||||
})
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=params)
|
||||
|
||||
with patch("app.tasks.runner.requests") as mock_req:
|
||||
mock_req.get.side_effect = ConnectionError("fetch failed")
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, _PARAMS)
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, params)
|
||||
|
||||
conn = sqlite3.connect(tmp_db)
|
||||
row = conn.execute(
|
||||
|
|
@ -153,169 +156,3 @@ def test_run_task_unknown_type_marks_failed(tmp_db: Path):
|
|||
).fetchone()
|
||||
conn.close()
|
||||
assert row[0] == "failed"
|
||||
|
||||
|
||||
# ── cf-orch path ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_orch_client_mock(vision_json: str) -> MagicMock:
|
||||
"""Build a CFOrchClient mock whose task_allocate context manager returns an Allocation."""
|
||||
alloc = MagicMock()
|
||||
alloc.url = "http://cf-vlm.local:8000"
|
||||
alloc.model = "bartowski--qwen2-vl-7b-instruct-gguf"
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(return_value=alloc)
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
client = MagicMock()
|
||||
client.task_allocate.return_value = cm
|
||||
return client
|
||||
|
||||
|
||||
def test_run_task_photo_analysis_orch_success(tmp_db: Path):
|
||||
"""Cloud path: CFOrchClient.task_allocate is used when GPU_SERVER_URL is set."""
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
||||
|
||||
chat_resp = MagicMock()
|
||||
chat_resp.json.return_value = {"choices": [{"message": {"content": _VISION_JSON}}]}
|
||||
chat_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("app.tasks.runner.requests") as mock_req, \
|
||||
patch.dict("os.environ", {"GPU_SERVER_URL": "http://cf-orch.local:8700"}), \
|
||||
patch("app.tasks.runner.httpx") as mock_httpx, \
|
||||
patch("circuitforge_orch.client.CFOrchClient") as MockClient:
|
||||
|
||||
mock_req.get.return_value.content = b"fake_image_bytes"
|
||||
mock_req.get.return_value.raise_for_status = lambda: None
|
||||
mock_httpx.post.return_value = chat_resp
|
||||
|
||||
client_instance = _make_orch_client_mock(_VISION_JSON)
|
||||
MockClient.return_value = client_instance
|
||||
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, _PARAMS)
|
||||
|
||||
conn = sqlite3.connect(tmp_db)
|
||||
score_row = conn.execute(
|
||||
"SELECT photo_analysis_json FROM trust_scores WHERE listing_id=1"
|
||||
).fetchone()
|
||||
task_row = conn.execute(
|
||||
"SELECT status FROM background_tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert task_row[0] == "completed"
|
||||
parsed = json.loads(score_row[0])
|
||||
assert parsed["authenticity_signal"] == "genuine_product_photo"
|
||||
|
||||
|
||||
def test_run_task_photo_analysis_orch_uses_image_assessment_task(tmp_db: Path):
|
||||
"""task_allocate must be called with product='snipe', task='image_assessment'."""
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
||||
|
||||
chat_resp = MagicMock()
|
||||
chat_resp.json.return_value = {"choices": [{"message": {"content": _VISION_JSON}}]}
|
||||
chat_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("app.tasks.runner.requests") as mock_req, \
|
||||
patch.dict("os.environ", {"GPU_SERVER_URL": "http://cf-orch.local:8700"}), \
|
||||
patch("app.tasks.runner.httpx") as mock_httpx, \
|
||||
patch("circuitforge_orch.client.CFOrchClient") as MockClient:
|
||||
|
||||
mock_req.get.return_value.content = b"fake_image_bytes"
|
||||
mock_req.get.return_value.raise_for_status = lambda: None
|
||||
mock_httpx.post.return_value = chat_resp
|
||||
|
||||
client_instance = _make_orch_client_mock(_VISION_JSON)
|
||||
MockClient.return_value = client_instance
|
||||
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, _PARAMS)
|
||||
|
||||
client_instance.task_allocate.assert_called_once_with("snipe", "image_assessment")
|
||||
|
||||
|
||||
def test_run_task_photo_analysis_orch_sends_image_url_content(tmp_db: Path):
|
||||
"""Vision payload must include image_url content block with data URI."""
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
||||
|
||||
captured_body: dict = {}
|
||||
|
||||
def capture_post(url, **kwargs):
|
||||
nonlocal captured_body
|
||||
if "/v1/chat/completions" in url:
|
||||
captured_body = kwargs.get("json", {})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"choices": [{"message": {"content": _VISION_JSON}}]}
|
||||
resp.raise_for_status = MagicMock()
|
||||
return resp
|
||||
|
||||
with patch("app.tasks.runner.requests") as mock_req, \
|
||||
patch.dict("os.environ", {"GPU_SERVER_URL": "http://cf-orch.local:8700"}), \
|
||||
patch("app.tasks.runner.httpx") as mock_httpx, \
|
||||
patch("circuitforge_orch.client.CFOrchClient") as MockClient:
|
||||
|
||||
mock_req.get.return_value.content = b"fake_image_bytes"
|
||||
mock_req.get.return_value.raise_for_status = lambda: None
|
||||
mock_httpx.post.side_effect = capture_post
|
||||
|
||||
client_instance = _make_orch_client_mock(_VISION_JSON)
|
||||
MockClient.return_value = client_instance
|
||||
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, _PARAMS)
|
||||
|
||||
user_content = captured_body["messages"][1]["content"]
|
||||
image_blocks = [b for b in user_content if b.get("type") == "image_url"]
|
||||
assert image_blocks, "No image_url content block found in vision payload"
|
||||
url = image_blocks[0]["image_url"]["url"]
|
||||
assert url.startswith("data:image/jpeg;base64,"), f"Unexpected image URL format: {url[:40]}"
|
||||
|
||||
|
||||
def test_run_task_photo_analysis_orch_task_not_found_falls_back(tmp_db: Path):
|
||||
"""TaskNotFound from cf-orch → graceful fallback to local LLMRouter."""
|
||||
from circuitforge_orch.client import TaskNotFound
|
||||
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(side_effect=TaskNotFound("snipe", "image_assessment"))
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
client_instance = MagicMock()
|
||||
client_instance.task_allocate.return_value = cm
|
||||
|
||||
with patch("app.tasks.runner.requests") as mock_req, \
|
||||
patch.dict("os.environ", {"GPU_SERVER_URL": "http://cf-orch.local:8700"}), \
|
||||
patch("circuitforge_orch.client.CFOrchClient", return_value=client_instance), \
|
||||
patch("app.tasks.runner._assess_via_local_llm", return_value=_VISION_JSON) as mock_local:
|
||||
|
||||
mock_req.get.return_value.content = b"fake_image_bytes"
|
||||
mock_req.get.return_value.raise_for_status = lambda: None
|
||||
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, _PARAMS)
|
||||
|
||||
mock_local.assert_called_once()
|
||||
|
||||
conn = sqlite3.connect(tmp_db)
|
||||
task_row = conn.execute(
|
||||
"SELECT status FROM background_tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert task_row[0] == "completed"
|
||||
|
||||
|
||||
def test_run_task_photo_analysis_non_json_response_writes_raw(tmp_db: Path):
|
||||
"""Non-JSON LLM response is stored with parse_error flag rather than crashing."""
|
||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
||||
|
||||
with patch("app.tasks.runner.requests") as mock_req, \
|
||||
patch("app.tasks.runner._assess_via_local_llm", return_value="not valid json at all"):
|
||||
mock_req.get.return_value.content = b"fake_image_bytes"
|
||||
mock_req.get.return_value.raise_for_status = lambda: None
|
||||
run_task(tmp_db, task_id, "trust_photo_analysis", 1, _PARAMS)
|
||||
|
||||
conn = sqlite3.connect(tmp_db)
|
||||
score_row = conn.execute(
|
||||
"SELECT photo_analysis_json FROM trust_scores WHERE listing_id=1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
parsed = json.loads(score_row[0])
|
||||
assert parsed.get("parse_error") is True
|
||||
assert "raw_response" in parsed
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.db.models import Seller
|
||||
from app.trust.aggregator import Aggregator
|
||||
|
||||
_ALL_20 = {k: 20 for k in ["account_age", "feedback_count", "feedback_ratio", "price_vs_market", "category_history"]}
|
||||
|
||||
|
||||
def _iso_days_ago(n: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=n)).isoformat()
|
||||
|
||||
|
||||
def test_composite_sum_of_five_signals():
|
||||
agg = Aggregator()
|
||||
|
|
@ -140,193 +132,3 @@ def test_new_account_not_flagged_when_age_absent():
|
|||
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=scraper_seller)
|
||||
assert "new_account" not in result.red_flags_json
|
||||
assert "account_under_30_days" not in result.red_flags_json
|
||||
|
||||
|
||||
# ── zero_feedback ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_zero_feedback_adds_flag():
|
||||
"""seller.feedback_count == 0 must add zero_feedback flag."""
|
||||
agg = Aggregator()
|
||||
seller = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="u",
|
||||
account_age_days=365, feedback_count=0, feedback_ratio=1.0,
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=False, seller=seller)
|
||||
assert "zero_feedback" in result.red_flags_json
|
||||
|
||||
|
||||
def test_zero_feedback_caps_composite_at_35():
|
||||
"""Even with perfect other signals (all 20/20), zero feedback caps composite at 35."""
|
||||
agg = Aggregator()
|
||||
seller = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="u",
|
||||
account_age_days=365, feedback_count=0, feedback_ratio=1.0,
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=False, seller=seller)
|
||||
assert result.composite_score <= 35
|
||||
|
||||
|
||||
# ── long_on_market ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_long_on_market_flagged_when_thresholds_met():
|
||||
"""times_seen >= 5 AND listing age >= 14 days → long_on_market fires."""
|
||||
agg = Aggregator()
|
||||
result = agg.aggregate(
|
||||
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
|
||||
times_seen=5, first_seen_at=_iso_days_ago(20),
|
||||
)
|
||||
assert "long_on_market" in result.red_flags_json
|
||||
|
||||
|
||||
def test_long_on_market_not_flagged_when_too_few_sightings():
|
||||
"""times_seen < 5 must NOT trigger long_on_market even if listing is old."""
|
||||
agg = Aggregator()
|
||||
result = agg.aggregate(
|
||||
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
|
||||
times_seen=4, first_seen_at=_iso_days_ago(30),
|
||||
)
|
||||
assert "long_on_market" not in result.red_flags_json
|
||||
|
||||
|
||||
def test_long_on_market_not_flagged_when_too_recent():
|
||||
"""times_seen >= 5 but only seen for < 14 days → long_on_market must NOT fire."""
|
||||
agg = Aggregator()
|
||||
result = agg.aggregate(
|
||||
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
|
||||
times_seen=10, first_seen_at=_iso_days_ago(5),
|
||||
)
|
||||
assert "long_on_market" not in result.red_flags_json
|
||||
|
||||
|
||||
# ── significant_price_drop ────────────────────────────────────────────────────
|
||||
|
||||
def test_significant_price_drop_flagged():
|
||||
"""price >= 20% below price_at_first_seen → significant_price_drop fires."""
|
||||
agg = Aggregator()
|
||||
result = agg.aggregate(
|
||||
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
|
||||
price=75.00, price_at_first_seen=100.00, # 25% drop
|
||||
)
|
||||
assert "significant_price_drop" in result.red_flags_json
|
||||
|
||||
|
||||
def test_significant_price_drop_not_flagged_when_drop_is_small():
|
||||
"""< 20% drop must NOT trigger significant_price_drop."""
|
||||
agg = Aggregator()
|
||||
result = agg.aggregate(
|
||||
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
|
||||
price=95.00, price_at_first_seen=100.00, # 5% drop
|
||||
)
|
||||
assert "significant_price_drop" not in result.red_flags_json
|
||||
|
||||
|
||||
def test_significant_price_drop_not_flagged_when_no_prior_price():
|
||||
"""price_at_first_seen=None (first sighting) must NOT fire significant_price_drop."""
|
||||
agg = Aggregator()
|
||||
result = agg.aggregate(
|
||||
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
|
||||
price=50.00, price_at_first_seen=None,
|
||||
)
|
||||
assert "significant_price_drop" not in result.red_flags_json
|
||||
|
||||
|
||||
# ── declining_ratio (high-volume seller edge case, snipe#52) ─────────────────
|
||||
|
||||
def test_declining_ratio_soft_flag_for_high_volume_seller():
|
||||
"""High-volume seller (count > 500) with declining but not catastrophic ratio
|
||||
gets declining_ratio soft flag, NOT the hard established_bad_actor flag.
|
||||
|
||||
Edge case: 12-month ratio may reflect only a small recent sample for sellers
|
||||
with large lifetime feedback counts — hard-flagging is disproportionate.
|
||||
"""
|
||||
agg = Aggregator()
|
||||
scores = {k: 10 for k in ["account_age", "feedback_count",
|
||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
||||
high_vol = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="u",
|
||||
account_age_days=2000, feedback_count=800, # count > 500
|
||||
feedback_ratio=0.75, # < 0.80 but > 0.60
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=high_vol)
|
||||
assert "declining_ratio" in result.red_flags_json
|
||||
assert "established_bad_actor" not in result.red_flags_json
|
||||
|
||||
|
||||
def test_established_bad_actor_still_fires_for_catastrophic_high_volume_ratio():
|
||||
"""High-volume seller (count > 500) with catastrophically bad ratio (< 60%)
|
||||
still gets the hard established_bad_actor flag — not just declining_ratio."""
|
||||
agg = Aggregator()
|
||||
scores = {k: 10 for k in ["account_age", "feedback_count",
|
||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
||||
bad_high_vol = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="u",
|
||||
account_age_days=2000, feedback_count=800,
|
||||
feedback_ratio=0.50, # < 0.60 threshold → still hard flag
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=bad_high_vol)
|
||||
assert "established_bad_actor" in result.red_flags_json
|
||||
assert "declining_ratio" not in result.red_flags_json
|
||||
|
||||
|
||||
# ── established retailer ──────────────────────────────────────────────────────
|
||||
|
||||
def test_established_retailer_suppresses_duplicate_photo():
|
||||
"""feedback_count >= 1000 (established retailer) must suppress duplicate_photo flag."""
|
||||
agg = Aggregator()
|
||||
retailer = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="u",
|
||||
account_age_days=1800, feedback_count=5000, feedback_ratio=0.99,
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=True, seller=retailer)
|
||||
assert "duplicate_photo" not in result.red_flags_json
|
||||
|
||||
|
||||
def test_non_retailer_does_not_suppress_duplicate_photo():
|
||||
"""feedback_count < 1000 — duplicate_photo must still fire when hash matches."""
|
||||
agg = Aggregator()
|
||||
seller = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="u",
|
||||
account_age_days=365, feedback_count=50, feedback_ratio=0.99,
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=True, seller=seller)
|
||||
assert "duplicate_photo" in result.red_flags_json
|
||||
|
||||
|
||||
# ── #52: buyer-only / returning seller (ratio=0.0, count>0) ──────────────────
|
||||
|
||||
def test_zero_ratio_with_count_gives_no_recent_seller_data_flag():
|
||||
"""Seller with 117 lifetime feedbacks (buyer-only) has ratio=0.0 parsed from page.
|
||||
Must get no_recent_seller_data soft flag, NOT established_bad_actor."""
|
||||
agg = Aggregator()
|
||||
scores = {k: 10 for k in ["account_age", "feedback_count",
|
||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
||||
buyer_only = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="jjcpryz",
|
||||
account_age_days=1200, feedback_count=117, feedback_ratio=0.0,
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=buyer_only)
|
||||
assert "no_recent_seller_data" in result.red_flags_json
|
||||
assert "established_bad_actor" not in result.red_flags_json
|
||||
|
||||
|
||||
|
||||
def test_established_bad_actor_still_fires_for_genuinely_bad_ratio():
|
||||
"""ratio=0.75 (not zero) with moderate count → established_bad_actor still fires."""
|
||||
agg = Aggregator()
|
||||
scores = {k: 10 for k in ["account_age", "feedback_count",
|
||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
||||
bad = Seller(
|
||||
platform="ebay", platform_seller_id="u", username="u",
|
||||
account_age_days=500, feedback_count=100, feedback_ratio=0.75,
|
||||
category_history_json="{}",
|
||||
)
|
||||
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=bad)
|
||||
assert "established_bad_actor" in result.red_flags_json
|
||||
assert "no_recent_seller_data" not in result.red_flags_json
|
||||
|
|
|
|||
|
|
@ -43,26 +43,3 @@ def test_no_market_data_returns_none():
|
|||
scores = scorer.score(_seller(), market_median=None, listing_price=950.0)
|
||||
# None signals "data unavailable" — aggregator will set score_is_partial=True
|
||||
assert scores["price_vs_market"] is None
|
||||
|
||||
|
||||
def test_zero_ratio_with_nonzero_count_returns_none():
|
||||
"""ratio=0.0 with count>0 means eBay didn't show a 12-month percentage.
|
||||
Must return None (missing data) not 0 (catastrophically bad)."""
|
||||
scorer = MetadataScorer()
|
||||
scores = scorer.score(
|
||||
_seller(feedback_ratio=0.0, feedback_count=117),
|
||||
market_median=None, listing_price=500.0,
|
||||
)
|
||||
assert scores["feedback_ratio"] is None
|
||||
|
||||
|
||||
def test_zero_ratio_with_zero_count_scores_low():
|
||||
"""feedback_ratio=0.0 with count=0 is a real 'no data at all' case, not missing."""
|
||||
scorer = MetadataScorer()
|
||||
scores = scorer.score(
|
||||
_seller(feedback_ratio=0.0, feedback_count=0),
|
||||
market_median=None, listing_price=500.0,
|
||||
)
|
||||
# count=0 means zero_feedback; ratio=0 with count=0 is the standard no-history path
|
||||
# (not the "missing 12-month window" path)
|
||||
assert scores["feedback_ratio"] == 5 # ratio < 0.90 → 5
|
||||
|
|
|
|||
|
|
@ -22,15 +22,11 @@
|
|||
<meta name="twitter:description" content="Free eBay trust scorer. Catches scammers before you bid. No account required." />
|
||||
<meta name="twitter:image" content="https://menagerie.circuitforge.tech/snipe/og-image.png" />
|
||||
<link rel="canonical" href="https://menagerie.circuitforge.tech/snipe" />
|
||||
<!-- FOFT guard: prevents dark flash before CSS bundle loads.
|
||||
theme.css overrides both html and body backgrounds via var(--color-surface)
|
||||
once loaded, so this only applies for the brief pre-bundle window. -->
|
||||
<!-- Inline background prevents blank flash before CSS bundle loads -->
|
||||
<!-- Matches --color-surface dark tactical theme from theme.css -->
|
||||
<style>
|
||||
html, body { margin: 0; background: #0d1117; min-height: 100vh; }
|
||||
</style>
|
||||
<!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted.
|
||||
Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. -->
|
||||
<script>(function(){if(/localhost|127\.0\.0\.1/.test(location.hostname))return;var s=document.createElement('script');s.defer=true;s.dataset.domain=location.hostname+',circuitforge.tech';s.dataset.api='https://analytics.circuitforge.tech/api/event';s.src='https://analytics.circuitforge.tech/js/script.js';document.head.appendChild(s);})();</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
|
||||
|
|
|
|||
|
|
@ -21,23 +21,18 @@ import { useMotion } from './composables/useMotion'
|
|||
import { useSnipeMode } from './composables/useSnipeMode'
|
||||
import { useTheme } from './composables/useTheme'
|
||||
import { useKonamiCode } from './composables/useKonamiCode'
|
||||
import { useCandycoreMode } from './composables/useCandycoreMode'
|
||||
import { useSessionStore } from './stores/session'
|
||||
import { useBlocklistStore } from './stores/blocklist'
|
||||
import { usePreferencesStore } from './stores/preferences'
|
||||
import { useReportedStore } from './stores/reported'
|
||||
import AppNav from './components/AppNav.vue'
|
||||
import FeedbackButton from './components/FeedbackButton.vue'
|
||||
|
||||
const motion = useMotion()
|
||||
const { activate, restore } = useSnipeMode()
|
||||
const { restore: restoreTheme } = useTheme()
|
||||
const { restore: restoreCandy, useWordTrigger } = useCandycoreMode()
|
||||
useWordTrigger()
|
||||
const session = useSessionStore()
|
||||
const blocklistStore = useBlocklistStore()
|
||||
const preferencesStore = usePreferencesStore()
|
||||
const reportedStore = useReportedStore()
|
||||
const route = useRoute()
|
||||
|
||||
useKonamiCode(activate)
|
||||
|
|
@ -45,11 +40,9 @@ useKonamiCode(activate)
|
|||
onMounted(async () => {
|
||||
restore() // re-apply snipe mode from localStorage on hard reload
|
||||
restoreTheme() // re-apply explicit theme override on hard reload
|
||||
restoreCandy() // re-apply candycore mode from localStorage on hard reload
|
||||
await session.bootstrap() // fetch tier + feature flags from API
|
||||
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
|
||||
preferencesStore.load() // load user preferences after session resolves
|
||||
reportedStore.load() // pre-load reported sellers so cards show badge immediately
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -61,12 +54,6 @@ onMounted(async () => {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
/* Global keyboard focus indicator — safety net so no stylesheet can silently remove focus rings */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-body, sans-serif);
|
||||
color: var(--color-text, #e6edf3);
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Reset module-level cache and fetch mock between tests
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
// Reset module-level cache so each test starts clean
|
||||
const mod = await import('../composables/useCurrency')
|
||||
mod._resetCacheForTest()
|
||||
})
|
||||
|
||||
const MOCK_RATES: Record<string, number> = {
|
||||
USD: 1,
|
||||
GBP: 0.79,
|
||||
EUR: 0.92,
|
||||
JPY: 151.5,
|
||||
CAD: 1.36,
|
||||
}
|
||||
|
||||
function mockFetchSuccess(rates = MOCK_RATES) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rates }),
|
||||
}))
|
||||
}
|
||||
|
||||
function mockFetchFailure() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
|
||||
}
|
||||
|
||||
describe('convertFromUSD', () => {
|
||||
it('returns the same amount for USD (no conversion)', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD } = await import('../composables/useCurrency')
|
||||
const result = await convertFromUSD(100, 'USD')
|
||||
expect(result).toBe(100)
|
||||
// fetch should not be called for USD passthrough
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('converts USD to GBP using fetched rates', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(100, 'GBP')
|
||||
expect(result).toBeCloseTo(79, 1)
|
||||
})
|
||||
|
||||
it('converts USD to JPY using fetched rates', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(10, 'JPY')
|
||||
expect(result).toBeCloseTo(1515, 1)
|
||||
})
|
||||
|
||||
it('returns the original amount when rates are unavailable (network failure)', async () => {
|
||||
mockFetchFailure()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(100, 'EUR')
|
||||
expect(result).toBe(100)
|
||||
})
|
||||
|
||||
it('returns the original amount when the currency code is unknown', async () => {
|
||||
mockFetchSuccess({ USD: 1, EUR: 0.92 }) // no XYZ rate
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(50, 'XYZ')
|
||||
expect(result).toBe(50)
|
||||
})
|
||||
|
||||
it('only calls fetch once when called concurrently (deduplication)', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
await Promise.all([
|
||||
convertFromUSD(100, 'GBP'),
|
||||
convertFromUSD(200, 'EUR'),
|
||||
convertFromUSD(50, 'CAD'),
|
||||
])
|
||||
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('formats USD amount with dollar sign', async () => {
|
||||
mockFetchSuccess()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await formatPrice(99.99, 'USD')
|
||||
expect(result).toMatch(/^\$99\.99$|^\$100$/) // Intl rounding may vary
|
||||
expect(result).toContain('$')
|
||||
})
|
||||
|
||||
it('formats GBP amount with correct symbol', async () => {
|
||||
mockFetchSuccess()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await formatPrice(100, 'GBP')
|
||||
// GBP 79 — expect pound sign or "GBP" prefix
|
||||
expect(result).toMatch(/[£]|GBP/)
|
||||
})
|
||||
|
||||
it('formats JPY without decimal places (Intl rounds to zero decimals)', async () => {
|
||||
mockFetchSuccess()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await formatPrice(10, 'JPY')
|
||||
// 10 * 151.5 = 1515 JPY — no decimal places for JPY
|
||||
expect(result).toMatch(/¥1,515|JPY.*1,515|¥1515/)
|
||||
})
|
||||
|
||||
it('falls back gracefully on network failure, showing USD', async () => {
|
||||
mockFetchFailure()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
// With failed rates, conversion returns original amount and uses Intl with target currency
|
||||
// This may throw if Intl doesn't know EUR — but the function should not throw
|
||||
const result = await formatPrice(50, 'EUR')
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPriceUSD', () => {
|
||||
it('formats a USD amount synchronously', async () => {
|
||||
const { formatPriceUSD } = await import('../composables/useCurrency')
|
||||
const result = formatPriceUSD(1234.5)
|
||||
// Intl output varies by runtime locale data; check structure not exact string
|
||||
expect(result).toContain('$')
|
||||
expect(result).toContain('1,234')
|
||||
})
|
||||
|
||||
it('formats zero as a USD string', async () => {
|
||||
const { formatPriceUSD } = await import('../composables/useCurrency')
|
||||
const result = formatPriceUSD(0)
|
||||
expect(result).toContain('$')
|
||||
expect(result).toMatch(/\$0/)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,12 +2,6 @@
|
|||
Dark tactical theme: near-black surfaces, amber accent, trust-signal colours.
|
||||
ALL color/font/spacing tokens live here — nowhere else.
|
||||
Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage).
|
||||
|
||||
Planned theme variants (add as [data-theme="<name>"] blocks using the same token set):
|
||||
solarized-dark — Ethan Schoonover's Solarized dark palette, amber accent
|
||||
solarized-light — Solarized light palette, amber accent
|
||||
high-contrast — WCAG AAA minimum contrast ratios, no mid-grey text
|
||||
colorblind — Deuteranopia-safe trust signal colours (blue/orange instead of green/red)
|
||||
*/
|
||||
|
||||
/* ── Snipe — dark tactical (default) ───────────────
|
||||
|
|
@ -87,7 +81,7 @@
|
|||
Snipe Mode data attribute overrides this via higher specificity.
|
||||
*/
|
||||
/* Explicit dark override — beats OS preference when user forces dark in Settings */
|
||||
[data-theme="dark"]:not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
||||
[data-theme="dark"]:not([data-snipe-mode="active"]) {
|
||||
--color-surface: #0d1117;
|
||||
--color-surface-2: #161b22;
|
||||
--color-surface-raised: #1c2129;
|
||||
|
|
@ -113,7 +107,7 @@
|
|||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
||||
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]) {
|
||||
/* Surfaces — warm cream, like a tactical field notebook */
|
||||
--color-surface: #f8f5ee;
|
||||
--color-surface-2: #f0ece3;
|
||||
|
|
@ -153,7 +147,7 @@
|
|||
}
|
||||
|
||||
/* Explicit light override — beats OS preference when user forces light in Settings */
|
||||
[data-theme="light"]:not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
||||
[data-theme="light"]:not([data-snipe-mode="active"]) {
|
||||
--color-surface: #f8f5ee;
|
||||
--color-surface-2: #f0ece3;
|
||||
--color-surface-raised: #e8e3d8;
|
||||
|
|
@ -178,56 +172,6 @@
|
|||
--shadow-lg: 0 10px 30px rgba(60,45,20,0.2), 0 4px 8px rgba(60,45,20,0.1);
|
||||
}
|
||||
|
||||
/* ── Candycore easter egg theme ─────────────────────
|
||||
Activated by typing "neon" outside a form field (tribute to artist Neon).
|
||||
Palette sourced from snipe_v0_Neon_IPad_Paint.jpeg:
|
||||
purple-black sky + lavender primary + cyan glow + yellow crown + pink text.
|
||||
Stored as 'cf-candycore' in localStorage.
|
||||
Applied: document.documentElement.dataset.candycore = 'active'
|
||||
NOTE: Snipe Mode is declared last and overrides this when both are active.
|
||||
*/
|
||||
[data-candycore="active"] {
|
||||
--app-primary: #c77dff;
|
||||
--app-primary-hover: #a855f7;
|
||||
--app-primary-light: rgba(199, 125, 255, 0.15);
|
||||
|
||||
/* Purple-black night sky */
|
||||
--color-surface: #08051a;
|
||||
--color-surface-2: #100d28;
|
||||
--color-surface-raised: #1a1248;
|
||||
|
||||
/* Purple glow borders */
|
||||
--color-border: rgba(199, 125, 255, 0.20);
|
||||
--color-border-light: rgba(199, 125, 255, 0.10);
|
||||
|
||||
/* Candy-floss text — pink-white, muted bubblegum */
|
||||
--color-text: #ffd6f5;
|
||||
--color-text-muted: #f09099;
|
||||
--color-text-inverse: #08051a;
|
||||
|
||||
/* Trust signals — straight from the painting */
|
||||
--trust-high: #00c8e0; /* cyan (outline glow) = good */
|
||||
--trust-mid: #ffe520; /* yellow (crown stripe) = caution */
|
||||
--trust-low: #ff6eb4; /* hot pink = danger */
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #00c8e0;
|
||||
--color-error: #ff6eb4;
|
||||
--color-warning: #ffe520;
|
||||
--color-info: #c77dff;
|
||||
--color-accent: #00c8e0; /* cyan accent */
|
||||
|
||||
/* Purple glow shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(199, 125, 255, 0.12);
|
||||
--shadow-md: 0 4px 12px rgba(199, 125, 255, 0.20);
|
||||
--shadow-lg: 0 10px 30px rgba(199, 125, 255, 0.28);
|
||||
|
||||
/* Glow helpers (used in scoped styles if needed) */
|
||||
--candy-glow-xs: rgba(199, 125, 255, 0.08);
|
||||
--candy-glow-sm: rgba(199, 125, 255, 0.18);
|
||||
--candy-glow-md: rgba(199, 125, 255, 0.45);
|
||||
}
|
||||
|
||||
/* ── Snipe Mode easter egg theme ─────────────────── */
|
||||
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
|
||||
/* Applied: document.documentElement.dataset.snipeMode = 'active' */
|
||||
|
|
@ -268,7 +212,7 @@ html {
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body { margin: 0; min-height: 100vh; background: var(--color-surface); }
|
||||
body { margin: 0; min-height: 100vh; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
|
|
|
|||
|
|
@ -1,398 +0,0 @@
|
|||
<template>
|
||||
<div class="alert-bell-wrap" ref="wrapRef">
|
||||
<!-- Bell trigger button -->
|
||||
<button
|
||||
ref="bellRef"
|
||||
class="alert-bell"
|
||||
:class="{ 'alert-bell--active': panelOpen }"
|
||||
:aria-label="unreadCount > 0 ? `${unreadCount} new watch alert${unreadCount === 1 ? '' : 's'}` : 'Watch alerts'"
|
||||
:aria-expanded="panelOpen"
|
||||
aria-haspopup="true"
|
||||
@click="togglePanel"
|
||||
>
|
||||
<BellIcon class="alert-bell__icon" aria-hidden="true" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="alert-badge"
|
||||
aria-hidden="true"
|
||||
>{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Polite live region — announces count changes without moving focus -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{{ liveAnnouncement }}
|
||||
</div>
|
||||
|
||||
<!-- Alert panel -->
|
||||
<Transition name="panel">
|
||||
<div
|
||||
v-if="panelOpen"
|
||||
class="alert-panel"
|
||||
role="dialog"
|
||||
aria-label="Watch alerts"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div class="alert-panel__header">
|
||||
<span class="alert-panel__title">Watch Alerts</span>
|
||||
<button
|
||||
v-if="store.alerts.length > 0"
|
||||
class="alert-panel__clear"
|
||||
@click="onDismissAll"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
<button
|
||||
class="alert-panel__close"
|
||||
aria-label="Close alerts panel"
|
||||
@click="closePanel"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="alert-panel__state">
|
||||
Loading…
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.alerts.length === 0" class="alert-panel__state">
|
||||
<span aria-hidden="true">🔔</span>
|
||||
<p>No new alerts. Enable monitoring on a saved search to get notified.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="alert-list" role="list">
|
||||
<li
|
||||
v-for="alert in store.alerts"
|
||||
:key="alert.id"
|
||||
class="alert-card"
|
||||
>
|
||||
<div class="alert-card__body">
|
||||
<p class="alert-card__title">{{ alert.title }}</p>
|
||||
<div class="alert-card__meta">
|
||||
<span class="alert-card__price">${{ alert.price.toFixed(2) }}</span>
|
||||
<span class="alert-card__score" :class="scoreClass(alert.trust_score)">
|
||||
Trust {{ alert.trust_score }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert-card__actions">
|
||||
<a
|
||||
v-if="alert.url"
|
||||
:href="alert.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="alert-card__view"
|
||||
:aria-label="`View listing: ${alert.title}`"
|
||||
>
|
||||
View on eBay
|
||||
</a>
|
||||
<button
|
||||
class="alert-card__dismiss"
|
||||
:aria-label="`Dismiss alert: ${alert.title}`"
|
||||
@click="onDismiss(alert.id)"
|
||||
@keydown.delete.prevent="onDismiss(alert.id)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { BellIcon } from '@heroicons/vue/24/outline'
|
||||
import { useAlertsStore } from '../stores/alerts'
|
||||
|
||||
const store = useAlertsStore()
|
||||
const panelOpen = ref(false)
|
||||
const bellRef = ref<HTMLButtonElement | null>(null)
|
||||
const wrapRef = ref<HTMLDivElement | null>(null)
|
||||
const liveAnnouncement = ref('')
|
||||
|
||||
const unreadCount = computed(() => store.unreadCount)
|
||||
|
||||
// Announce count changes to screen readers via the polite live region.
|
||||
watch(unreadCount, (count, prev) => {
|
||||
if (count > prev) {
|
||||
liveAnnouncement.value = `${count} new watch alert${count === 1 ? '' : 's'}`
|
||||
// Reset after announcement so repeat counts still fire.
|
||||
setTimeout(() => { liveAnnouncement.value = '' }, 1500)
|
||||
}
|
||||
})
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value) store.fetchAlerts()
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen.value = false
|
||||
bellRef.value?.focus()
|
||||
}
|
||||
|
||||
async function onDismiss(id: number) {
|
||||
await store.dismiss(id)
|
||||
if (store.alerts.length === 0) {
|
||||
// Return focus to bell when last alert is dismissed.
|
||||
panelOpen.value = false
|
||||
bellRef.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function onDismissAll() {
|
||||
await store.dismissAll()
|
||||
panelOpen.value = false
|
||||
bellRef.value?.focus()
|
||||
}
|
||||
|
||||
function scoreClass(score: number) {
|
||||
if (score >= 75) return 'score--high'
|
||||
if (score >= 50) return 'score--medium'
|
||||
return 'score--low'
|
||||
}
|
||||
|
||||
// Close on outside click.
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (wrapRef.value && !wrapRef.value.contains(e.target as Node)) {
|
||||
panelOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAlerts()
|
||||
// Poll for new alerts every 2 minutes while the app is open.
|
||||
const interval = setInterval(() => store.fetchAlerts(), 120_000)
|
||||
document.addEventListener('click', handleOutsideClick)
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(interval)
|
||||
document.removeEventListener('click', handleOutsideClick)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.alert-bell-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alert-bell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.alert-bell:hover,
|
||||
.alert-bell--active {
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
background: var(--app-primary-light);
|
||||
}
|
||||
|
||||
.alert-bell__icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.alert-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
background: var(--color-error, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.alert-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: min(360px, 92vw);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alert-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.alert-panel__title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alert-panel__clear {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
.alert-panel__clear:hover { color: var(--color-error); }
|
||||
|
||||
.alert-panel__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
transition: color 150ms ease;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.alert-panel__close:hover { color: var(--color-error); }
|
||||
|
||||
.alert-panel__state {
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Alert list */
|
||||
.alert-list {
|
||||
list-style: none;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.alert-card:hover { background: var(--color-surface); }
|
||||
.alert-card:last-child { border-bottom: none; }
|
||||
|
||||
.alert-card__body { flex: 1; min-width: 0; }
|
||||
|
||||
.alert-card__title {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--space-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.alert-card__meta {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-card__price {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.alert-card__score {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.score--high { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.score--medium { background: rgba(234,179,8,0.15); color: #eab308; }
|
||||
.score--low { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||
|
||||
.alert-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-card__view {
|
||||
font-size: 0.75rem;
|
||||
color: var(--app-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.alert-card__view:hover { background: var(--app-primary-light); }
|
||||
|
||||
.alert-card__dismiss {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
transition: border-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
.alert-card__dismiss:hover { border-color: var(--color-error); color: var(--color-error); }
|
||||
|
||||
/* Transition */
|
||||
.panel-enter-active,
|
||||
.panel-leave-active { transition: opacity 120ms ease, transform 120ms ease; }
|
||||
.panel-enter-from,
|
||||
.panel-leave-to { opacity: 0; transform: translateY(-6px); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel-enter-active,
|
||||
.panel-leave-active { transition: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<!-- Desktop: persistent sidebar (≥1024px) -->
|
||||
<!-- Mobile: bottom tab bar (<1024px) -->
|
||||
<nav class="app-sidebar" role="navigation" aria-label="Sidebar">
|
||||
<nav class="app-sidebar" role="navigation" aria-label="Main navigation">
|
||||
<!-- Brand -->
|
||||
<div class="sidebar__brand">
|
||||
<RouterLink to="/" class="sidebar__logo">
|
||||
|
|
@ -32,20 +32,17 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Settings + alert bell at bottom -->
|
||||
<!-- Settings at bottom -->
|
||||
<div class="sidebar__footer">
|
||||
<div class="sidebar__footer-row">
|
||||
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||
<span class="sidebar__label">Settings</span>
|
||||
</RouterLink>
|
||||
<AlertBell v-if="session.isLoggedIn || session.tier === 'local'" class="sidebar__bell" />
|
||||
</div>
|
||||
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||
<span class="sidebar__label">Settings</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile bottom tab bar -->
|
||||
<nav class="app-tabbar" role="navigation" aria-label="Tab bar">
|
||||
<nav class="app-tabbar" role="navigation" aria-label="Main navigation">
|
||||
<ul class="tabbar__links" role="list">
|
||||
<li v-for="link in mobileLinks" :key="link.to">
|
||||
<RouterLink
|
||||
|
|
@ -72,11 +69,8 @@ import {
|
|||
ShieldExclamationIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { useSnipeMode } from '../composables/useSnipeMode'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import AlertBell from './AlertBell.vue'
|
||||
|
||||
const { active: isSnipeMode, deactivate } = useSnipeMode()
|
||||
const session = useSessionStore()
|
||||
|
||||
const navLinks = computed(() => [
|
||||
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
||||
|
|
@ -87,7 +81,7 @@ const navLinks = computed(() => [
|
|||
const mobileLinks = [
|
||||
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
||||
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
|
||||
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Blocklist' },
|
||||
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
|
||||
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
|
||||
]
|
||||
</script>
|
||||
|
|
@ -208,20 +202,6 @@ const mobileLinks = [
|
|||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.sidebar__footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar__footer-row .sidebar__link {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar__bell {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||
.app-tabbar {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -81,9 +81,6 @@
|
|||
{{ flagLabel(flag) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="sellerReported" class="card__reported-badge" aria-label="You reported this seller to eBay">
|
||||
⚐ Reported to eBay
|
||||
</p>
|
||||
<p v-if="pendingSignalNames.length" class="card__score-pending">
|
||||
↻ Updating: {{ pendingSignalNames.join(', ') }}
|
||||
</p>
|
||||
|
|
@ -189,18 +186,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Listing, TrustScore, Seller } from '../stores/search'
|
||||
import { useSearchStore } from '../stores/search'
|
||||
import { useBlocklistStore } from '../stores/blocklist'
|
||||
import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
|
||||
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
||||
import { formatPrice, formatPriceUSD } from '../composables/useCurrency'
|
||||
import { usePreferencesStore } from '../stores/preferences'
|
||||
|
||||
const { enabled: trustSignalEnabled } = useTrustSignalPref()
|
||||
const prefsStore = usePreferencesStore()
|
||||
|
||||
const props = defineProps<{
|
||||
listing: Listing
|
||||
|
|
@ -209,7 +203,6 @@ const props = defineProps<{
|
|||
marketPrice: number | null
|
||||
selected?: boolean
|
||||
selectMode?: boolean
|
||||
sellerReported?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
|
@ -382,26 +375,15 @@ const isSteal = computed(() => {
|
|||
return props.listing.price < props.marketPrice * 0.8
|
||||
})
|
||||
|
||||
// Async price display — show USD synchronously while rates load, then update
|
||||
const formattedPrice = ref(formatPriceUSD(props.listing.price))
|
||||
const formattedMarket = ref(props.marketPrice ? formatPriceUSD(props.marketPrice) : '')
|
||||
const formattedPrice = computed(() => {
|
||||
const sym = props.listing.currency === 'USD' ? '$' : props.listing.currency + ' '
|
||||
return `${sym}${props.listing.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
})
|
||||
|
||||
async function _updatePrices() {
|
||||
const currency = prefsStore.displayCurrency
|
||||
formattedPrice.value = await formatPrice(props.listing.price, currency)
|
||||
if (props.marketPrice) {
|
||||
formattedMarket.value = await formatPrice(props.marketPrice, currency)
|
||||
} else {
|
||||
formattedMarket.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Update when the listing, marketPrice, or display currency changes
|
||||
watch(
|
||||
[() => props.listing.price, () => props.marketPrice, () => prefsStore.displayCurrency],
|
||||
() => { _updatePrices() },
|
||||
{ immediate: true },
|
||||
)
|
||||
const formattedMarket = computed(() => {
|
||||
if (!props.marketPrice) return ''
|
||||
return `$${props.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -547,17 +529,6 @@ watch(
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card__reported-badge {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px var(--space-2);
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.card__partial-warning {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-warning);
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
<template>
|
||||
<div class="search-progress" role="status" aria-label="Search in progress" aria-live="polite">
|
||||
<!-- Indeterminate progress bar -->
|
||||
<div class="progress-track" aria-hidden="true">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status line -->
|
||||
<p class="progress-label">
|
||||
Searching <strong>{{ platformLabel }}</strong> for <strong>{{ query }}</strong>…
|
||||
</p>
|
||||
|
||||
<!-- Skeleton listing cards -->
|
||||
<div class="skeleton-list" aria-hidden="true">
|
||||
<div v-for="n in 4" :key="n" class="skeleton-card">
|
||||
<div class="skeleton-thumb"></div>
|
||||
<div class="skeleton-body">
|
||||
<div class="skeleton-line skeleton-line--title"></div>
|
||||
<div class="skeleton-line skeleton-line--meta"></div>
|
||||
<div class="skeleton-footer">
|
||||
<div class="skeleton-chip"></div>
|
||||
<div class="skeleton-chip skeleton-chip--price"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ query: string; platform?: string }>()
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
ebay: 'eBay',
|
||||
mercari: 'Mercari',
|
||||
poshmark: 'Poshmark',
|
||||
}
|
||||
|
||||
const platformLabel = computed(() =>
|
||||
PLATFORM_LABELS[props.platform ?? 'ebay'] ?? props.platform ?? 'eBay'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-progress {
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* ── Indeterminate progress bar ───────────────── */
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 40%;
|
||||
background: var(--app-primary);
|
||||
border-radius: var(--radius-full);
|
||||
animation: progress-slide 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
/* ── Status label ─────────────────────────────── */
|
||||
.progress-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-label strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Skeleton cards ───────────────────────────── */
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.skeleton-thumb {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line--title {
|
||||
width: 70%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.skeleton-line--meta {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.skeleton-footer {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.skeleton-chip {
|
||||
height: 22px;
|
||||
width: 64px;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-full);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-chip--price {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
/* Stagger shimmer so cards don't all pulse in sync */
|
||||
.skeleton-card:nth-child(2) .skeleton-line,
|
||||
.skeleton-card:nth-child(2) .skeleton-thumb,
|
||||
.skeleton-card:nth-child(2) .skeleton-chip { animation-delay: 0.15s; }
|
||||
|
||||
.skeleton-card:nth-child(3) .skeleton-line,
|
||||
.skeleton-card:nth-child(3) .skeleton-thumb,
|
||||
.skeleton-card:nth-child(3) .skeleton-chip { animation-delay: 0.3s; }
|
||||
|
||||
.skeleton-card:nth-child(4) .skeleton-line,
|
||||
.skeleton-card:nth-child(4) .skeleton-thumb,
|
||||
.skeleton-card:nth-child(4) .skeleton-chip { animation-delay: 0.45s; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.skeleton-thumb {
|
||||
width: 72px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.skeleton-line--title { width: 85%; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useSnipeMode } from './useSnipeMode'
|
||||
|
||||
const LS_KEY = 'cf-candycore'
|
||||
const DATA_ATTR = 'candycore'
|
||||
|
||||
// Module-level ref — shared across all callers
|
||||
const active = ref(false)
|
||||
|
||||
/**
|
||||
* Candycore easter egg theme — activated by typing "neon" outside a form field.
|
||||
* Tribute to artist Neon, whose iPad painting (snipe_v0_Neon_IPad_Paint.jpeg)
|
||||
* defined the candy palette: lavender primary, cyan glow, yellow crown, bubblegum pink.
|
||||
*
|
||||
* Mutually exclusive with Snipe Mode (each deactivates the other).
|
||||
* Stores state in localStorage under 'cf-candycore'.
|
||||
*/
|
||||
export function useCandycoreMode() {
|
||||
const snipe = useSnipeMode(false /* no sound on deactivate */)
|
||||
|
||||
function _playCandySound() {
|
||||
try {
|
||||
const ctx = new AudioContext()
|
||||
// Ascending arpeggio: C5 → E5 → G5 → C6
|
||||
const notes = [523.25, 659.25, 783.99, 1046.50]
|
||||
const step = 0.08
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const t = ctx.currentTime + i * step
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(freq, t)
|
||||
gain.gain.setValueAtTime(0, t)
|
||||
gain.gain.linearRampToValueAtTime(0.22, t + 0.01)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + step * 1.4)
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start(t)
|
||||
osc.stop(t + step * 1.5)
|
||||
})
|
||||
|
||||
setTimeout(() => ctx.close(), (notes.length * step + 0.3) * 1000)
|
||||
} catch {
|
||||
// Web Audio API unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function activate() {
|
||||
// Deactivate Snipe Mode if it's running — can't have both
|
||||
if (snipe.active.value) snipe.deactivate()
|
||||
active.value = true
|
||||
document.documentElement.dataset[DATA_ATTR] = 'active'
|
||||
localStorage.setItem(LS_KEY, 'active')
|
||||
_playCandySound()
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
active.value = false
|
||||
delete document.documentElement.dataset[DATA_ATTR]
|
||||
localStorage.removeItem(LS_KEY)
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (localStorage.getItem(LS_KEY) === 'active') {
|
||||
active.value = true
|
||||
document.documentElement.dataset[DATA_ATTR] = 'active'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a document keydown listener that fires activate() when the user
|
||||
* types "neon" outside of any form field. Call from component setup().
|
||||
* The listener is automatically removed when the calling component unmounts.
|
||||
*/
|
||||
function useWordTrigger() {
|
||||
const TARGET = 'neon'
|
||||
let buffer = ''
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName ?? ''
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
if (e.key.length !== 1) return // skip modifier/arrow keys
|
||||
|
||||
buffer = (buffer + e.key.toLowerCase()).slice(-TARGET.length)
|
||||
if (buffer === TARGET) {
|
||||
buffer = ''
|
||||
if (active.value) deactivate()
|
||||
else activate()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKey))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKey))
|
||||
}
|
||||
|
||||
return { active, activate, deactivate, restore, useWordTrigger }
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
/**
|
||||
* useCurrency — live exchange rate conversion from USD to a target display currency.
|
||||
*
|
||||
* Rates are fetched lazily on first use from open.er-api.com (free, no key required).
|
||||
* A module-level cache with a 1-hour TTL prevents redundant network calls.
|
||||
* On fetch failure the composable falls back silently to USD display.
|
||||
*/
|
||||
|
||||
const ER_API_URL = 'https://open.er-api.com/v6/latest/USD'
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
interface RateCache {
|
||||
rates: Record<string, number>
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
// Module-level cache shared across all composable instances
|
||||
let _cache: RateCache | null = null
|
||||
let _inflight: Promise<Record<string, number>> | null = null
|
||||
|
||||
async function _fetchRates(): Promise<Record<string, number>> {
|
||||
const now = Date.now()
|
||||
|
||||
if (_cache && now - _cache.fetchedAt < CACHE_TTL_MS) {
|
||||
return _cache.rates
|
||||
}
|
||||
|
||||
// Deduplicate concurrent calls — reuse the same in-flight fetch
|
||||
if (_inflight) {
|
||||
return _inflight
|
||||
}
|
||||
|
||||
_inflight = (async () => {
|
||||
try {
|
||||
const res = await fetch(ER_API_URL)
|
||||
if (!res.ok) throw new Error(`ER-API responded ${res.status}`)
|
||||
const data = await res.json()
|
||||
const rates: Record<string, number> = data.rates ?? {}
|
||||
_cache = { rates, fetchedAt: Date.now() }
|
||||
return rates
|
||||
} catch {
|
||||
// Return cached stale data if available, otherwise empty object (USD passthrough)
|
||||
return _cache?.rates ?? {}
|
||||
} finally {
|
||||
_inflight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return _inflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an amount in USD to the target currency using the latest exchange rates.
|
||||
* Returns the original amount unchanged if rates are unavailable or the currency is USD.
|
||||
*/
|
||||
export async function convertFromUSD(amountUSD: number, targetCurrency: string): Promise<number> {
|
||||
if (targetCurrency === 'USD') return amountUSD
|
||||
const rates = await _fetchRates()
|
||||
const rate = rates[targetCurrency]
|
||||
if (!rate) return amountUSD
|
||||
return amountUSD * rate
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a USD amount as a localized string in the target currency.
|
||||
* Fetches exchange rates lazily. Falls back to USD display if rates are unavailable.
|
||||
*
|
||||
* Returns a plain USD string synchronously on first call while rates load;
|
||||
* callers should use a ref that updates once the promise resolves.
|
||||
*/
|
||||
export async function formatPrice(amountUSD: number, currency: string): Promise<string> {
|
||||
const converted = await convertFromUSD(amountUSD, currency)
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(converted)
|
||||
} catch {
|
||||
// Fallback if Intl doesn't know the currency code
|
||||
return `${currency} ${converted.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous USD-only formatter for use before rates have loaded.
|
||||
*/
|
||||
export function formatPriceUSD(amountUSD: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amountUSD)
|
||||
}
|
||||
|
||||
// Exported for testing — allows resetting module-level cache between test cases
|
||||
export function _resetCacheForTest(): void {
|
||||
_cache = null
|
||||
_inflight = null
|
||||
}
|
||||
|
|
@ -58,9 +58,6 @@ export function useSnipeMode(audioEnabled = true) {
|
|||
}
|
||||
|
||||
function activate() {
|
||||
// Clear candycore if it's on — can't have both
|
||||
delete document.documentElement.dataset.candycore
|
||||
localStorage.removeItem('cf-candycore')
|
||||
active.value = true
|
||||
document.documentElement.dataset[DATA_ATTR] = 'active'
|
||||
localStorage.setItem(LS_KEY, 'active')
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface WatchAlert {
|
||||
id: number
|
||||
saved_search_id: number
|
||||
platform_listing_id: string
|
||||
title: string
|
||||
price: number
|
||||
currency: string
|
||||
trust_score: number
|
||||
url: string | null
|
||||
first_alerted_at: string
|
||||
dismissed_at: string | null
|
||||
}
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? ''
|
||||
|
||||
export const useAlertsStore = defineStore('alerts', () => {
|
||||
const alerts = ref<WatchAlert[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchAlerts(includeDismissed = false) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BASE}/api/alerts${includeDismissed ? '?include_dismissed=true' : ''}`,
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
const data = await res.json()
|
||||
alerts.value = data.alerts
|
||||
unreadCount.value = data.unread_count
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load alerts'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function dismiss(alertId: number) {
|
||||
await fetch(`${BASE}/api/alerts/${alertId}/dismiss`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
alerts.value = alerts.value.filter((a) => a.id !== alertId)
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
}
|
||||
|
||||
async function dismissAll() {
|
||||
await fetch(`${BASE}/api/alerts/dismiss-all`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
alerts.value = []
|
||||
unreadCount.value = 0
|
||||
}
|
||||
|
||||
return { alerts, unreadCount, loading, error, fetchAlerts, dismiss, dismissAll }
|
||||
})
|
||||
|
|
@ -9,19 +9,8 @@ export interface UserPreferences {
|
|||
ebay?: string
|
||||
}
|
||||
}
|
||||
community?: {
|
||||
blocklist_share?: boolean
|
||||
}
|
||||
display?: {
|
||||
currency?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CURRENCY_LS_KEY = 'snipe:currency'
|
||||
const DEFAULT_CURRENCY = 'USD'
|
||||
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
|
||||
export const usePreferencesStore = defineStore('preferences', () => {
|
||||
const session = useSessionStore()
|
||||
const prefs = ref<UserPreferences>({})
|
||||
|
|
@ -30,36 +19,15 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
|||
|
||||
const affiliateOptOut = computed(() => prefs.value.affiliate?.opt_out ?? false)
|
||||
const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '')
|
||||
const communityBlocklistShare = computed(() => prefs.value.community?.blocklist_share ?? false)
|
||||
|
||||
// displayCurrency: DB preference for logged-in users, localStorage for anon users
|
||||
const displayCurrency = computed((): string => {
|
||||
return prefs.value.display?.currency ?? DEFAULT_CURRENCY
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!session.isLoggedIn) {
|
||||
// Anonymous user: read currency from localStorage
|
||||
const stored = localStorage.getItem(CURRENCY_LS_KEY)
|
||||
if (stored) {
|
||||
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: stored } }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!session.isLoggedIn) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/preferences`)
|
||||
const res = await fetch('/api/preferences')
|
||||
if (res.ok) {
|
||||
const data: UserPreferences = await res.json()
|
||||
// Migration: if logged in but no DB preference, fall back to localStorage value
|
||||
if (!data.display?.currency) {
|
||||
const lsVal = localStorage.getItem(CURRENCY_LS_KEY)
|
||||
if (lsVal) {
|
||||
data.display = { ...data.display, currency: lsVal }
|
||||
}
|
||||
}
|
||||
prefs.value = data
|
||||
prefs.value = await res.json()
|
||||
}
|
||||
} catch {
|
||||
// Non-cloud deploy or network error — preferences unavailable
|
||||
|
|
@ -72,7 +40,7 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
|||
if (!session.isLoggedIn) return
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/preferences`, {
|
||||
const res = await fetch('/api/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, value }),
|
||||
|
|
@ -97,34 +65,14 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
|||
await setPref('affiliate.byok_ids.ebay', id.trim() || null)
|
||||
}
|
||||
|
||||
async function setCommunityBlocklistShare(value: boolean) {
|
||||
await setPref('community.blocklist_share', value)
|
||||
}
|
||||
|
||||
async function setDisplayCurrency(code: string) {
|
||||
const upper = code.toUpperCase()
|
||||
// Optimistic local update so the UI reacts immediately
|
||||
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: upper } }
|
||||
if (session.isLoggedIn) {
|
||||
await setPref('display.currency', upper)
|
||||
} else {
|
||||
// Anonymous user: persist to localStorage only
|
||||
localStorage.setItem(CURRENCY_LS_KEY, upper)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prefs,
|
||||
loading,
|
||||
error,
|
||||
affiliateOptOut,
|
||||
affiliateByokId,
|
||||
communityBlocklistShare,
|
||||
displayCurrency,
|
||||
load,
|
||||
setAffiliateOptOut,
|
||||
setAffiliateByokId,
|
||||
setCommunityBlocklistShare,
|
||||
setDisplayCurrency,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
|
||||
/**
|
||||
* Tracks sellers the user has already reported to eBay T&S.
|
||||
* Persisted server-side for logged-in users; falls back to a session-local
|
||||
* Set for guests so the UI still suppresses duplicate prompts within a session.
|
||||
*/
|
||||
export const useReportedStore = defineStore('reported', () => {
|
||||
const reportedIds = ref<Set<string>>(new Set())
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/reported`)
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { reported: string[] }
|
||||
reportedIds.value = new Set(data.reported)
|
||||
}
|
||||
} catch {
|
||||
// Non-cloud deploy or network error — start with empty set
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markReported(sellers: Array<{ platform_seller_id: string; username?: string | null }>) {
|
||||
// Optimistic update — add to local set immediately
|
||||
const next = new Set(reportedIds.value)
|
||||
for (const s of sellers) next.add(s.platform_seller_id)
|
||||
reportedIds.value = next
|
||||
|
||||
// Persist server-side (best-effort — no rollback on failure)
|
||||
try {
|
||||
await fetch(`${apiBase}/api/reported`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sellers: sellers.map(s => ({
|
||||
platform_seller_id: s.platform_seller_id,
|
||||
username: s.username ?? null,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Persist failed — local set already updated, good enough for session
|
||||
}
|
||||
}
|
||||
|
||||
function isReported(platformSellerId: string): boolean {
|
||||
return reportedIds.value.has(platformSellerId)
|
||||
}
|
||||
|
||||
return { reportedIds, loading, load, markReported, isReported }
|
||||
})
|
||||
|
|
@ -59,11 +59,6 @@ export interface SavedSearch {
|
|||
filters_json: string // JSON blob of SearchFilters subset
|
||||
created_at: string | null
|
||||
last_run_at: string | null
|
||||
// Monitor settings (migration 014)
|
||||
monitor_enabled: boolean
|
||||
poll_interval_min: number
|
||||
min_trust_score: number
|
||||
last_checked_at: string | null
|
||||
}
|
||||
|
||||
export interface SearchParamsResult {
|
||||
|
|
@ -98,7 +93,6 @@ export interface SearchFilters {
|
|||
mustExclude?: string // comma-separated; forwarded to eBay -term AND client-side
|
||||
categoryId?: string // eBay category ID (e.g. "27386" = Graphics/Video Cards)
|
||||
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
|
||||
platform?: string // target platform; defaults to 'ebay' when omitted
|
||||
}
|
||||
|
||||
// ── Session cache ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -151,7 +145,6 @@ export const useSearchStore = defineStore('search', () => {
|
|||
_abort?.abort()
|
||||
_abort = null
|
||||
loading.value = false
|
||||
closeUpdates()
|
||||
}
|
||||
|
||||
async function search(q: string, filters: SearchFilters = {}) {
|
||||
|
|
@ -165,6 +158,8 @@ export const useSearchStore = defineStore('search', () => {
|
|||
error.value = null
|
||||
|
||||
try {
|
||||
// TODO: POST /api/search with { query: q, filters }
|
||||
// API does not exist yet — stub returns empty results
|
||||
// VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite)
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
const params = new URLSearchParams({ q })
|
||||
|
|
@ -179,37 +174,51 @@ export const useSearchStore = defineStore('search', () => {
|
|||
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
|
||||
if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim())
|
||||
if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter)
|
||||
if (filters.platform && filters.platform !== 'ebay') params.set('platform', filters.platform)
|
||||
|
||||
// Use the async endpoint: returns 202 immediately with a session_id, then
|
||||
// streams listings + trust scores via SSE as the scrape completes.
|
||||
const res = await fetch(`${apiBase}/api/search/async?${params}`, { signal })
|
||||
const res = await fetch(`${apiBase}/api/search?${params}`, { signal })
|
||||
if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`)
|
||||
|
||||
const data = await res.json() as {
|
||||
session_id: string
|
||||
status: 'queued'
|
||||
listings: Listing[]
|
||||
trust_scores: Record<string, TrustScore>
|
||||
sellers: Record<string, Seller>
|
||||
market_price: number | null
|
||||
adapter_used: 'api' | 'scraper'
|
||||
affiliate_active: boolean
|
||||
session_id: string | null
|
||||
}
|
||||
|
||||
// HTTP 202 received — scraping is underway in the background.
|
||||
// Stay in loading state until the first "listings" SSE event arrives.
|
||||
// loading.value stays true; enriching tracks the SSE stream being open.
|
||||
enriching.value = true
|
||||
_openUpdates(data.session_id, apiBase)
|
||||
results.value = data.listings ?? []
|
||||
trustScores.value = new Map(Object.entries(data.trust_scores ?? {}))
|
||||
sellers.value = new Map(Object.entries(data.sellers ?? {}))
|
||||
marketPrice.value = data.market_price ?? null
|
||||
adapterUsed.value = data.adapter_used ?? null
|
||||
affiliateActive.value = data.affiliate_active ?? false
|
||||
saveCache({
|
||||
query: q,
|
||||
results: results.value,
|
||||
trustScores: data.trust_scores ?? {},
|
||||
sellers: data.sellers ?? {},
|
||||
marketPrice: marketPrice.value,
|
||||
adapterUsed: adapterUsed.value,
|
||||
})
|
||||
|
||||
// Open SSE stream if any scores are partial and a session_id was provided
|
||||
const hasPartial = Object.values(data.trust_scores ?? {}).some(ts => ts.score_is_partial)
|
||||
if (data.session_id && hasPartial) {
|
||||
_openUpdates(data.session_id, apiBase)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
// User cancelled — clear loading but don't surface as an error
|
||||
results.value = []
|
||||
loading.value = false
|
||||
} else {
|
||||
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||
results.value = []
|
||||
loading.value = false
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
_abort = null
|
||||
}
|
||||
// Note: loading.value is NOT set to false here — it stays true until the
|
||||
// first "listings" SSE event arrives (see _openUpdates handler below).
|
||||
}
|
||||
|
||||
function closeUpdates() {
|
||||
|
|
@ -220,116 +229,35 @@ export const useSearchStore = defineStore('search', () => {
|
|||
enriching.value = false
|
||||
}
|
||||
|
||||
// Internal type for typed SSE events from the async search endpoint
|
||||
type _AsyncListingsEvent = {
|
||||
type: 'listings'
|
||||
listings: Listing[]
|
||||
trust_scores: Record<string, TrustScore>
|
||||
sellers: Record<string, Seller>
|
||||
market_price: number | null
|
||||
adapter_used: 'api' | 'scraper'
|
||||
affiliate_active: boolean
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type _MarketPriceEvent = {
|
||||
type: 'market_price'
|
||||
market_price: number | null
|
||||
}
|
||||
|
||||
type _UpdateEvent = {
|
||||
type: 'update'
|
||||
platform_listing_id: string
|
||||
trust_score: TrustScore
|
||||
seller: Seller
|
||||
market_price: number | null
|
||||
}
|
||||
|
||||
type _LegacyUpdateEvent = {
|
||||
platform_listing_id: string
|
||||
trust_score: TrustScore
|
||||
seller: Record<string, unknown>
|
||||
market_price: number | null
|
||||
}
|
||||
|
||||
type _SSEEvent =
|
||||
| _AsyncListingsEvent
|
||||
| _MarketPriceEvent
|
||||
| _UpdateEvent
|
||||
| _LegacyUpdateEvent
|
||||
|
||||
function _openUpdates(sessionId: string, apiBase: string) {
|
||||
// Close any pre-existing stream but preserve enriching state — caller sets it.
|
||||
if (_sse) {
|
||||
_sse.close()
|
||||
_sse = null
|
||||
}
|
||||
closeUpdates() // close any previous stream
|
||||
enriching.value = true
|
||||
|
||||
const es = new EventSource(`${apiBase}/api/updates/${sessionId}`)
|
||||
_sse = es
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const update = JSON.parse(e.data) as _SSEEvent
|
||||
|
||||
if ('type' in update) {
|
||||
// Typed events from the async search endpoint
|
||||
if (update.type === 'listings') {
|
||||
// First batch: hydrate store and transition out of loading state
|
||||
results.value = update.listings ?? []
|
||||
trustScores.value = new Map(Object.entries(update.trust_scores ?? {}))
|
||||
sellers.value = new Map(Object.entries(update.sellers ?? {}))
|
||||
marketPrice.value = update.market_price ?? null
|
||||
adapterUsed.value = update.adapter_used ?? null
|
||||
affiliateActive.value = update.affiliate_active ?? false
|
||||
saveCache({
|
||||
query: query.value,
|
||||
results: results.value,
|
||||
trustScores: update.trust_scores ?? {},
|
||||
sellers: update.sellers ?? {},
|
||||
marketPrice: marketPrice.value,
|
||||
adapterUsed: adapterUsed.value,
|
||||
})
|
||||
// Scrape complete — turn off the initial loading spinner.
|
||||
// enriching stays true while enrichment SSE is still open.
|
||||
loading.value = false
|
||||
} else if (update.type === 'market_price') {
|
||||
if (update.market_price != null) {
|
||||
marketPrice.value = update.market_price
|
||||
}
|
||||
} else if (update.type === 'update') {
|
||||
// Per-seller enrichment update (same as legacy format but typed)
|
||||
if (update.platform_listing_id && update.trust_score) {
|
||||
trustScores.value = new Map(trustScores.value)
|
||||
trustScores.value.set(update.platform_listing_id, update.trust_score)
|
||||
}
|
||||
if (update.seller?.platform_seller_id) {
|
||||
sellers.value = new Map(sellers.value)
|
||||
sellers.value.set(update.seller.platform_seller_id, update.seller)
|
||||
}
|
||||
if (update.market_price != null) {
|
||||
marketPrice.value = update.market_price
|
||||
}
|
||||
}
|
||||
// type: "error" — no special handling; stream will close via 'done'
|
||||
} else {
|
||||
// Legacy enrichment update (no type field) from synchronous search path
|
||||
const legacy = update as _LegacyUpdateEvent
|
||||
if (legacy.platform_listing_id && legacy.trust_score) {
|
||||
trustScores.value = new Map(trustScores.value)
|
||||
trustScores.value.set(legacy.platform_listing_id, legacy.trust_score)
|
||||
}
|
||||
if (legacy.seller) {
|
||||
const s = legacy.seller as Seller
|
||||
if (s.platform_seller_id) {
|
||||
sellers.value = new Map(sellers.value)
|
||||
sellers.value.set(s.platform_seller_id, s)
|
||||
}
|
||||
}
|
||||
if (legacy.market_price != null) {
|
||||
marketPrice.value = legacy.market_price
|
||||
const update = JSON.parse(e.data) as {
|
||||
platform_listing_id: string
|
||||
trust_score: TrustScore
|
||||
seller: Record<string, unknown>
|
||||
market_price: number | null
|
||||
}
|
||||
if (update.platform_listing_id && update.trust_score) {
|
||||
trustScores.value = new Map(trustScores.value)
|
||||
trustScores.value.set(update.platform_listing_id, update.trust_score)
|
||||
}
|
||||
if (update.seller) {
|
||||
const s = update.seller as Seller
|
||||
if (s.platform_seller_id) {
|
||||
sellers.value = new Map(sellers.value)
|
||||
sellers.value.set(s.platform_seller_id, s)
|
||||
}
|
||||
}
|
||||
if (update.market_price != null) {
|
||||
marketPrice.value = update.market_price
|
||||
}
|
||||
} catch {
|
||||
// malformed event — ignore
|
||||
}
|
||||
|
|
@ -340,8 +268,6 @@ export const useSearchStore = defineStore('search', () => {
|
|||
})
|
||||
|
||||
es.onerror = () => {
|
||||
// If loading is still true (never got a "listings" event), clear it
|
||||
loading.value = false
|
||||
closeUpdates()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ export const useSessionStore = defineStore('session', () => {
|
|||
const isLoggedIn = computed(() => isCloud.value && userId.value !== 'anonymous' && !isGuest.value)
|
||||
|
||||
async function bootstrap() {
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/session`)
|
||||
const res = await fetch('/api/session')
|
||||
if (!res.ok) return // local-mode with no session endpoint — keep defaults
|
||||
const data = await res.json()
|
||||
userId.value = data.user_id
|
||||
|
|
|
|||
|
|
@ -31,112 +31,41 @@
|
|||
<span v-if="item.last_run_at">Last run {{ formatDate(item.last_run_at) }}</span>
|
||||
<span v-else>Never run</span>
|
||||
· Saved {{ formatDate(item.created_at) }}
|
||||
<span v-if="item.last_checked_at" class="saved-card-checked">
|
||||
· Monitored {{ formatDate(item.last_checked_at) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="saved-card-right">
|
||||
<!-- Monitor toggle — only shown to paid+ users -->
|
||||
<div v-if="session.isPaid || session.tier === 'local'" class="monitor-section">
|
||||
<label class="monitor-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="monitor-toggle-input"
|
||||
:checked="item.monitor_enabled"
|
||||
:aria-label="`Monitor ${item.name}`"
|
||||
@change="onToggleMonitor(item, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="monitor-toggle-track" aria-hidden="true" />
|
||||
<span class="monitor-toggle-text">Monitor</span>
|
||||
</label>
|
||||
|
||||
<!-- Inline settings — only when enabled -->
|
||||
<Transition name="slide">
|
||||
<div v-if="item.monitor_enabled" class="monitor-settings">
|
||||
<label class="monitor-setting-label">
|
||||
Check every
|
||||
<input
|
||||
type="number"
|
||||
class="monitor-setting-input"
|
||||
:value="item.poll_interval_min"
|
||||
min="15"
|
||||
max="1440"
|
||||
step="15"
|
||||
:aria-label="`Poll interval for ${item.name} in minutes`"
|
||||
@change="onIntervalChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
|
||||
/>
|
||||
min
|
||||
<span class="monitor-hint">Min 15. 60 = hourly.</span>
|
||||
</label>
|
||||
<label class="monitor-setting-label">
|
||||
Trust ≥
|
||||
<input
|
||||
type="number"
|
||||
class="monitor-setting-input"
|
||||
:value="item.min_trust_score"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
:aria-label="`Minimum trust score for ${item.name}`"
|
||||
@change="onThresholdChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
|
||||
/>
|
||||
<span class="monitor-hint">0–100. 60 = medium confidence.</span>
|
||||
</label>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="saved-card-actions">
|
||||
<button class="saved-run-btn" type="button" @click="onRun(item)">
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
class="saved-delete-btn"
|
||||
type="button"
|
||||
:aria-label="`Delete saved search: ${item.name}`"
|
||||
@click="onDelete(item)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="saved-card-actions">
|
||||
<button class="saved-run-btn" type="button" @click="onRun(item)">
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
class="saved-delete-btn"
|
||||
type="button"
|
||||
:aria-label="`Delete saved search: ${item.name}`"
|
||||
@click="onDelete(item.id)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Undo toast for delete -->
|
||||
<Transition name="toast">
|
||||
<div v-if="pendingDelete" class="undo-toast" role="status" aria-live="polite">
|
||||
<span>Deleted "{{ pendingDelete.name }}"</span>
|
||||
<button class="undo-btn" @click="onUndoDelete">Undo</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useSavedSearchesStore } from '../stores/savedSearches'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import type { SavedSearch } from '../stores/savedSearches'
|
||||
|
||||
const store = useSavedSearchesStore()
|
||||
const session = useSessionStore()
|
||||
const router = useRouter()
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? ''
|
||||
|
||||
// Soft-delete state — holds for 3 seconds before committing
|
||||
const pendingDelete = ref<SavedSearch | null>(null)
|
||||
let deleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => store.fetchAll())
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
async function onRun(item: SavedSearch) {
|
||||
|
|
@ -146,65 +75,8 @@ async function onRun(item: SavedSearch) {
|
|||
router.push({ path: '/', query })
|
||||
}
|
||||
|
||||
function onDelete(item: SavedSearch) {
|
||||
// Soft-delete: show undo toast, commit after 3s.
|
||||
if (deleteTimer) clearTimeout(deleteTimer)
|
||||
pendingDelete.value = item
|
||||
deleteTimer = setTimeout(async () => {
|
||||
if (pendingDelete.value?.id === item.id) {
|
||||
await store.remove(item.id)
|
||||
pendingDelete.value = null
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function onUndoDelete() {
|
||||
if (deleteTimer) clearTimeout(deleteTimer)
|
||||
pendingDelete.value = null
|
||||
}
|
||||
|
||||
async function onToggleMonitor(item: SavedSearch, enabled: boolean) {
|
||||
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
monitor_enabled: enabled,
|
||||
poll_interval_min: item.poll_interval_min,
|
||||
min_trust_score: item.min_trust_score,
|
||||
}),
|
||||
})
|
||||
await store.fetchAll()
|
||||
}
|
||||
|
||||
async function onIntervalChange(item: SavedSearch, minutes: number) {
|
||||
if (isNaN(minutes) || minutes < 15) return
|
||||
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
monitor_enabled: item.monitor_enabled,
|
||||
poll_interval_min: minutes,
|
||||
min_trust_score: item.min_trust_score,
|
||||
}),
|
||||
})
|
||||
await store.fetchAll()
|
||||
}
|
||||
|
||||
async function onThresholdChange(item: SavedSearch, score: number) {
|
||||
if (isNaN(score)) return
|
||||
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
monitor_enabled: item.monitor_enabled,
|
||||
poll_interval_min: item.poll_interval_min,
|
||||
min_trust_score: score,
|
||||
}),
|
||||
})
|
||||
await store.fetchAll()
|
||||
async function onDelete(id: number) {
|
||||
await store.remove(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -255,12 +127,12 @@ async function onThresholdChange(item: SavedSearch, score: number) {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
max-width: 800px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.saved-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-surface-2);
|
||||
|
|
@ -302,131 +174,13 @@ async function onThresholdChange(item: SavedSearch, score: number) {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.saved-card-checked {
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
/* Right column: monitor section + action buttons */
|
||||
.saved-card-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.saved-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Monitor toggle */
|
||||
.monitor-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.monitor-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Visually hide the native checkbox but keep it accessible */
|
||||
.monitor-toggle-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.monitor-toggle-track {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-border);
|
||||
position: relative;
|
||||
transition: background 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.monitor-toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.monitor-toggle-input:checked + .monitor-toggle-track {
|
||||
background: var(--app-primary);
|
||||
}
|
||||
|
||||
.monitor-toggle-input:checked + .monitor-toggle-track::after {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
/* Focus ring on the label when the hidden checkbox is focused */
|
||||
.monitor-toggle-label:has(.monitor-toggle-input:focus-visible) .monitor-toggle-track {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.monitor-toggle-text {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Inline monitor settings */
|
||||
.monitor-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.monitor-setting-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.monitor-setting-input {
|
||||
width: 60px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.monitor-hint {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.saved-run-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--app-primary);
|
||||
|
|
@ -452,65 +206,13 @@ async function onThresholdChange(item: SavedSearch, score: number) {
|
|||
cursor: pointer;
|
||||
transition: border-color 150ms ease, color 150ms ease;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.saved-delete-btn:hover { border-color: var(--color-error); color: var(--color-error); }
|
||||
|
||||
/* Undo toast */
|
||||
.undo-toast {
|
||||
position: fixed;
|
||||
bottom: calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
z-index: 300;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.undo-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--app-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-inverse);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.slide-enter-active,
|
||||
.slide-leave-active { transition: opacity 150ms ease, max-height 200ms ease; max-height: 200px; overflow: hidden; }
|
||||
.slide-enter-from,
|
||||
.slide-leave-to { opacity: 0; max-height: 0; }
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
|
||||
.toast-enter-from,
|
||||
.toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.slide-enter-active, .slide-leave-active,
|
||||
.toast-enter-active, .toast-leave-active { transition: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.saved-header { padding: var(--space-4); }
|
||||
.saved-list { padding: var(--space-4); }
|
||||
.saved-card { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
||||
.saved-card-right { width: 100%; align-items: flex-start; }
|
||||
.saved-card-actions { width: 100%; justify-content: flex-end; }
|
||||
.monitor-section { width: 100%; align-items: flex-start; }
|
||||
.monitor-settings { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,29 +2,8 @@
|
|||
<div class="search-view">
|
||||
<!-- Search bar -->
|
||||
<header class="search-header">
|
||||
<div class="platform-tabs" role="tablist" aria-label="Search platform">
|
||||
<button
|
||||
v-for="p in PLATFORMS"
|
||||
:key="p.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="platform-tab"
|
||||
:class="{
|
||||
'platform-tab--active': filters.platform === p.value,
|
||||
'platform-tab--soon': !p.available,
|
||||
}"
|
||||
:aria-selected="filters.platform === p.value"
|
||||
:disabled="!p.available"
|
||||
:title="p.available ? p.label : `${p.label} — coming soon`"
|
||||
@click="p.available && (filters.platform = p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span v-if="!p.available" class="platform-tab__soon">soon</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="search-form" @submit.prevent="onSearch" role="search">
|
||||
<div class="search-form-row1">
|
||||
<template v-if="filters.platform === 'ebay' || !filters.platform">
|
||||
<label for="cat-select" class="sr-only">Category</label>
|
||||
<select
|
||||
id="cat-select"
|
||||
|
|
@ -41,7 +20,6 @@
|
|||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</template>
|
||||
<label for="search-input" class="sr-only">Search listings</label>
|
||||
<input
|
||||
id="search-input"
|
||||
|
|
@ -138,7 +116,6 @@
|
|||
|
||||
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
||||
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
||||
<template v-if="filters.platform === 'ebay' || !filters.platform">
|
||||
<h2 class="filter-section-heading filter-section-heading--search">
|
||||
eBay Search
|
||||
</h2>
|
||||
|
|
@ -239,7 +216,6 @@
|
|||
<p class="filter-pages-hint">Excludes forwarded to eBay on re-search</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<!-- ── Post-search Filters ────────────────────────────────────────── -->
|
||||
<!-- Applied locally to current results — no re-search needed. -->
|
||||
|
|
@ -379,9 +355,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading (scraping in progress, no results yet) -->
|
||||
<SearchProgress v-else-if="store.loading && !store.results.length" :query="store.query" :platform="filters.platform ?? 'ebay'" />
|
||||
|
||||
<!-- No results -->
|
||||
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
|
||||
<p>No listings found for <strong>{{ store.query }}</strong>.</p>
|
||||
|
|
@ -402,13 +375,8 @@
|
|||
</span>
|
||||
</p>
|
||||
<div class="toolbar-actions">
|
||||
<!-- Re-search indicator — loading while stale results are still visible -->
|
||||
<span v-if="store.loading && store.results.length" class="enriching-badge enriching-badge--searching" aria-live="polite" title="Fetching new results…">
|
||||
<span class="enriching-dot" aria-hidden="true"></span>
|
||||
Re-searching…
|
||||
</span>
|
||||
<!-- Live enrichment indicator — visible while SSE stream is open -->
|
||||
<span v-else-if="store.enriching" class="enriching-badge" aria-live="polite" title="Scores updating as seller data arrives">
|
||||
<span v-if="store.enriching" class="enriching-badge" aria-live="polite" title="Scores updating as seller data arrives">
|
||||
<span class="enriching-dot" aria-hidden="true"></span>
|
||||
Updating scores…
|
||||
</span>
|
||||
|
|
@ -466,7 +434,6 @@
|
|||
:market-price="store.marketPrice"
|
||||
:selected="selectedIds.has(listing.platform_listing_id)"
|
||||
:select-mode="selectMode"
|
||||
:seller-reported="reported.isReported(listing.seller_platform_id)"
|
||||
@toggle="toggleSelect(listing.platform_listing_id)"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -485,17 +452,14 @@ import type { Listing, TrustScore, SearchFilters, MustIncludeMode } from '../sto
|
|||
import { useSavedSearchesStore } from '../stores/savedSearches'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useBlocklistStore } from '../stores/blocklist'
|
||||
import { useReportedStore } from '../stores/reported'
|
||||
import ListingCard from '../components/ListingCard.vue'
|
||||
import LLMQueryPanel from '../components/LLMQueryPanel.vue'
|
||||
import SearchProgress from '../components/SearchProgress.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSearchStore()
|
||||
const savedStore = useSavedSearchesStore()
|
||||
const session = useSessionStore()
|
||||
const blocklist = useBlocklistStore()
|
||||
const reported = useReportedStore()
|
||||
const queryInput = ref('')
|
||||
|
||||
// ── Multi-select + bulk actions ───────────────────────────────────────────────
|
||||
|
|
@ -555,7 +519,6 @@ async function blockSelected() {
|
|||
function reportSelected() {
|
||||
const toReport = visibleListings.value.filter(l => selectedIds.value.has(l.platform_listing_id))
|
||||
// De-duplicate by seller — one report per seller covers all their listings
|
||||
const reportedEntries: Array<{ platform_seller_id: string; username: string | null }> = []
|
||||
const seenSellers = new Set<string>()
|
||||
for (const l of toReport) {
|
||||
if (l.seller_platform_id && !seenSellers.has(l.seller_platform_id)) {
|
||||
|
|
@ -567,12 +530,8 @@ function reportSelected() {
|
|||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
reportedEntries.push({ platform_seller_id: l.seller_platform_id, username: seller?.username ?? null })
|
||||
}
|
||||
}
|
||||
if (reportedEntries.length) {
|
||||
reported.markReported(reportedEntries)
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
|
|
@ -660,7 +619,6 @@ const DEFAULT_FILTERS: SearchFilters = {
|
|||
mustExclude: '',
|
||||
categoryId: '',
|
||||
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
||||
platform: 'ebay',
|
||||
}
|
||||
|
||||
const filters = reactive<SearchFilters>({ ...DEFAULT_FILTERS })
|
||||
|
|
@ -696,12 +654,6 @@ const parsedMustIncludeGroups = computed(() =>
|
|||
.filter(g => g.length > 0)
|
||||
)
|
||||
|
||||
const PLATFORMS: { value: string; label: string; available: boolean }[] = [
|
||||
{ value: 'ebay', label: 'eBay', available: true },
|
||||
{ value: 'mercari', label: 'Mercari', available: true },
|
||||
{ value: 'poshmark', label: 'Poshmark', available: false },
|
||||
]
|
||||
|
||||
const INCLUDE_MODES: { value: MustIncludeMode; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'any', label: 'Any' },
|
||||
|
|
@ -1480,16 +1432,6 @@ async function onSearch() {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enriching-badge--searching {
|
||||
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-info) 30%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.enriching-badge--searching .enriching-dot {
|
||||
background: var(--color-info);
|
||||
}
|
||||
|
||||
.enriching-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
|
@ -1826,53 +1768,4 @@ async function onSearch() {
|
|||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Platform tab strip ──────────────────────────────────────────────── */
|
||||
.platform-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.platform-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-tab:hover:not(:disabled):not(.platform-tab--active) {
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.platform-tab--active {
|
||||
background: var(--app-primary);
|
||||
border-color: var(--app-primary);
|
||||
color: var(--color-text-inverse);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.platform-tab--soon {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.platform-tab__soon {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -24,29 +24,6 @@
|
|||
<span class="toggle-btn__thumb" />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<!-- Community blocklist share — cloud signed-in users only -->
|
||||
<label v-if="session.isLoggedIn" class="settings-toggle">
|
||||
<div class="settings-toggle-text">
|
||||
<span class="settings-toggle-label">Share blocklist with community</span>
|
||||
<span class="settings-toggle-desc">
|
||||
When enabled, sellers you block are anonymously contributed to the
|
||||
community blocklist. Only the seller ID and flag reason are shared,
|
||||
never your identity. A consensus threshold prevents false positives.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ 'toggle-btn--on': communityBlocklistShare }"
|
||||
:aria-pressed="String(communityBlocklistShare)"
|
||||
:aria-busy="prefs.loading"
|
||||
aria-label="Share blocked sellers with community blocklist"
|
||||
@click="prefs.setCommunityBlocklistShare(!communityBlocklistShare)"
|
||||
>
|
||||
<span class="toggle-btn__track" />
|
||||
<span class="toggle-btn__thumb" />
|
||||
</button>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Appearance -->
|
||||
|
|
@ -69,96 +46,6 @@
|
|||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display currency -->
|
||||
<div class="settings-toggle">
|
||||
<div class="settings-toggle-text">
|
||||
<span class="settings-toggle-label">Display currency</span>
|
||||
<span class="settings-toggle-desc">
|
||||
Listing prices are converted from USD using live exchange rates.
|
||||
Rates update hourly.
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
id="display-currency"
|
||||
class="settings-select"
|
||||
:value="prefs.displayCurrency"
|
||||
aria-label="Select display currency"
|
||||
@change="prefs.setDisplayCurrency(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
|
||||
{{ opt.code }} — {{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- eBay Account Connection — paid+ only -->
|
||||
<section v-if="ebay.oauth_available && session.isLoggedIn" class="settings-section">
|
||||
<h2 class="settings-section-title">eBay Account</h2>
|
||||
|
||||
<!-- Connected state -->
|
||||
<div v-if="ebay.connected" class="ebay-connected">
|
||||
<div class="ebay-status-row">
|
||||
<span class="ebay-status-dot ebay-status-dot--on" aria-hidden="true" />
|
||||
<span class="settings-toggle-label">Connected</span>
|
||||
</div>
|
||||
<p class="settings-toggle-desc">
|
||||
Snipe uses your eBay account to fetch seller registration dates instantly
|
||||
via the Trading API, without Playwright scraping. This means faster, more
|
||||
accurate trust scores on every search.
|
||||
<span v-if="ebay.access_token_expired" class="ebay-warn">
|
||||
Your access token has expired — reconnect to restore instant enrichment.
|
||||
</span>
|
||||
</p>
|
||||
<div class="ebay-action-row">
|
||||
<button
|
||||
v-if="ebay.access_token_expired"
|
||||
class="ebay-btn ebay-btn--primary"
|
||||
:disabled="ebay.connecting"
|
||||
@click="startConnect"
|
||||
>
|
||||
Reconnect eBay account
|
||||
</button>
|
||||
<button
|
||||
class="ebay-btn ebay-btn--danger"
|
||||
:disabled="ebay.disconnecting"
|
||||
@click="disconnect"
|
||||
>
|
||||
{{ ebay.disconnecting ? 'Disconnecting…' : 'Disconnect' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not connected — paid tier -->
|
||||
<div v-else-if="session.isPaid || session.isPremium" class="ebay-disconnected">
|
||||
<p class="settings-toggle-desc">
|
||||
Connect your eBay account to enable instant seller registration date lookup
|
||||
via the Trading API. Without it, Snipe falls back to slower Playwright
|
||||
scraping (or Shopping API rate-limited calls) to determine account age.
|
||||
</p>
|
||||
<button
|
||||
class="ebay-btn ebay-btn--primary"
|
||||
:disabled="ebay.connecting"
|
||||
@click="startConnect"
|
||||
>
|
||||
{{ ebay.connecting ? 'Redirecting to eBay…' : 'Connect eBay account' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Not connected — free tier upsell -->
|
||||
<div v-else class="ebay-disconnected">
|
||||
<p class="settings-toggle-desc">
|
||||
Connect your eBay account for instant seller trust scoring without scraping.
|
||||
Available on Paid tier and above.
|
||||
</p>
|
||||
<a class="ebay-btn ebay-btn--upsell" href="/pricing" rel="noopener">
|
||||
Upgrade to Paid
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p v-if="ebay.error" class="settings-error" role="alert">{{ ebay.error }}</p>
|
||||
<p v-if="ebay.success" class="settings-success" role="status">{{ ebay.success }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Affiliate Links — only shown to signed-in cloud users -->
|
||||
|
|
@ -242,16 +129,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { usePreferencesStore } from '../stores/preferences'
|
||||
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
|
||||
const theme = useTheme()
|
||||
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
|
||||
|
|
@ -259,22 +143,9 @@ const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
|
|||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
]
|
||||
const currencyOptions: { code: string; label: string }[] = [
|
||||
{ code: 'USD', label: 'US Dollar' },
|
||||
{ code: 'EUR', label: 'Euro' },
|
||||
{ code: 'GBP', label: 'British Pound' },
|
||||
{ code: 'CAD', label: 'Canadian Dollar' },
|
||||
{ code: 'AUD', label: 'Australian Dollar' },
|
||||
{ code: 'JPY', label: 'Japanese Yen' },
|
||||
{ code: 'CHF', label: 'Swiss Franc' },
|
||||
{ code: 'MXN', label: 'Mexican Peso' },
|
||||
{ code: 'BRL', label: 'Brazilian Real' },
|
||||
{ code: 'INR', label: 'Indian Rupee' },
|
||||
]
|
||||
const session = useSessionStore()
|
||||
const prefs = usePreferencesStore()
|
||||
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
|
||||
const communityBlocklistShare = computed(() => prefs.communityBlocklistShare)
|
||||
|
||||
// Local input buffer for BYOK ID — synced from store, saved on blur/enter
|
||||
const byokInput = ref(prefs.affiliateByokId)
|
||||
|
|
@ -283,90 +154,6 @@ watch(() => prefs.affiliateByokId, (val) => { byokInput.value = val })
|
|||
function saveByokId() {
|
||||
prefs.setAffiliateByokId(byokInput.value)
|
||||
}
|
||||
|
||||
// ── eBay Account Connection ──────────────────────────────────────────────────
|
||||
|
||||
const ebay = reactive({
|
||||
oauth_available: false,
|
||||
connected: false,
|
||||
access_token_expired: false,
|
||||
scopes: [] as string[],
|
||||
connecting: false,
|
||||
disconnecting: false,
|
||||
error: '',
|
||||
success: '',
|
||||
})
|
||||
|
||||
async function fetchEbayStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/ebay/status')
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
ebay.oauth_available = data.oauth_available ?? false
|
||||
ebay.connected = data.connected ?? false
|
||||
ebay.access_token_expired = data.access_token_expired ?? false
|
||||
ebay.scopes = data.scopes ?? []
|
||||
} catch {
|
||||
// silently ignore — section stays hidden if fetch fails
|
||||
}
|
||||
}
|
||||
|
||||
async function startConnect() {
|
||||
ebay.connecting = true
|
||||
ebay.error = ''
|
||||
try {
|
||||
const res = await fetch('/api/ebay/connect')
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
ebay.error = body.detail ?? 'eBay connection unavailable.'
|
||||
return
|
||||
}
|
||||
const { auth_url } = await res.json()
|
||||
window.location.href = auth_url
|
||||
} catch {
|
||||
ebay.error = 'Could not reach the server. Try again.'
|
||||
ebay.connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
ebay.disconnecting = true
|
||||
ebay.error = ''
|
||||
ebay.success = ''
|
||||
try {
|
||||
const res = await fetch('/api/ebay/disconnect', { method: 'DELETE' })
|
||||
if (res.ok || res.status === 204) {
|
||||
ebay.connected = false
|
||||
ebay.access_token_expired = false
|
||||
ebay.scopes = []
|
||||
ebay.success = 'eBay account disconnected.'
|
||||
} else {
|
||||
ebay.error = 'Disconnect failed. Try again.'
|
||||
}
|
||||
} catch {
|
||||
ebay.error = 'Could not reach the server. Try again.'
|
||||
} finally {
|
||||
ebay.disconnecting = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchEbayStatus()
|
||||
|
||||
// Handle OAuth callback redirect params: ?ebay_connected=1 or ?ebay_error=access_denied
|
||||
const connected = route.query.ebay_connected
|
||||
const oauthError = route.query.ebay_error
|
||||
if (connected) {
|
||||
ebay.success = 'eBay account connected! Trust scores will now use the Trading API.'
|
||||
await fetchEbayStatus()
|
||||
router.replace({ query: { ...route.query, ebay_connected: undefined } })
|
||||
} else if (oauthError) {
|
||||
ebay.error = oauthError === 'access_denied'
|
||||
? 'eBay authorization was cancelled.'
|
||||
: `eBay OAuth error: ${oauthError}`
|
||||
router.replace({ query: { ...route.query, ebay_error: undefined } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -528,125 +315,13 @@ onMounted(async () => {
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- Error / success feedback ---- */
|
||||
/* ---- Error feedback ---- */
|
||||
.settings-error {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-danger, #f85149);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-select:focus {
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.settings-success {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-success, #3fb950);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- eBay Account section ---- */
|
||||
.ebay-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ebay-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.ebay-status-dot--on {
|
||||
background: var(--color-success, #3fb950);
|
||||
}
|
||||
|
||||
.ebay-connected,
|
||||
.ebay-disconnected {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ebay-warn {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-warning, #d29922);
|
||||
}
|
||||
|
||||
.ebay-action-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ebay-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ebay-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ebay-btn--primary {
|
||||
background: var(--app-primary);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
}
|
||||
|
||||
.ebay-btn--primary:hover:not(:disabled) { opacity: 0.85; }
|
||||
|
||||
.ebay-btn--danger {
|
||||
background: transparent;
|
||||
color: var(--color-danger, #f85149);
|
||||
border: 1px solid var(--color-danger, #f85149);
|
||||
}
|
||||
|
||||
.ebay-btn--danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-danger, #f85149) 12%, transparent);
|
||||
}
|
||||
|
||||
.ebay-btn--upsell {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ebay-btn--upsell:hover { opacity: 0.85; }
|
||||
|
||||
.ebay-btn:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-btn-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue