Compare commits
No commits in common. "main" and "v0.3.0" have entirely different histories.
107 changed files with 743 additions and 11501 deletions
56
.env.example
56
.env.example
|
|
@ -19,25 +19,6 @@ EBAY_SANDBOX_CERT_ID=
|
||||||
# production | sandbox
|
# production | sandbox
|
||||||
EBAY_ENV=production
|
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 ──────────────────────────────────────────────
|
# ── eBay Account Deletion Webhook ──────────────────────────────────────────────
|
||||||
# Register endpoint at https://developer.ebay.com/my/notification — required for
|
# Register endpoint at https://developer.ebay.com/my/notification — required for
|
||||||
# production key activation. Set EBAY_NOTIFICATION_ENDPOINT to the public HTTPS
|
# production key activation. Set EBAY_NOTIFICATION_ENDPOINT to the public HTTPS
|
||||||
|
|
@ -51,9 +32,6 @@ EBAY_WEBHOOK_VERIFY_SIGNATURES=true
|
||||||
# ── Database ───────────────────────────────────────────────────────────────────
|
# ── Database ───────────────────────────────────────────────────────────────────
|
||||||
SNIPE_DB=data/snipe.db
|
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) ─────────────────────────────
|
# ── Cloud mode (managed / menagerie instance only) ─────────────────────────────
|
||||||
# Leave unset for self-hosted / local use. When set, per-user DB isolation
|
# 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
|
# and Heimdall licensing are enabled. compose.cloud.yml sets CLOUD_MODE=true
|
||||||
|
|
@ -76,17 +54,13 @@ CF_APP_NAME=snipe
|
||||||
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
|
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
|
||||||
# EBAY_AFFILIATE_CAMPAIGN_ID=
|
# EBAY_AFFILIATE_CAMPAIGN_ID=
|
||||||
|
|
||||||
# ── LLM inference (Search with AI / photo analysis) ──────────────────────────
|
# ── LLM inference (vision / photo analysis) ──────────────────────────────────
|
||||||
# For self-hosted use, create config/llm.yaml from config/llm.yaml.example.
|
# circuitforge-core LLMRouter auto-detects backends from these env vars
|
||||||
# config/llm.yaml is the preferred way to configure backends (supports cf-orch,
|
# (no llm.yaml required). Backends are tried in this priority order:
|
||||||
# multiple fallback backends, per-backend model selection).
|
|
||||||
#
|
|
||||||
# As a quick alternative, circuitforge-core LLMRouter also auto-detects backends
|
|
||||||
# from these env vars when no llm.yaml is present:
|
|
||||||
# 1. ANTHROPIC_API_KEY → Claude API (cloud; requires Paid tier key)
|
# 1. ANTHROPIC_API_KEY → Claude API (cloud; requires Paid tier key)
|
||||||
# 2. OPENAI_API_KEY → OpenAI-compatible endpoint
|
# 2. OPENAI_API_KEY → OpenAI-compatible endpoint
|
||||||
# 3. OLLAMA_HOST → local Ollama (default: http://localhost:11434)
|
# 3. OLLAMA_HOST → local Ollama (default: http://localhost:11434)
|
||||||
# Leave all unset to disable LLM features (Search with AI won't be available).
|
# Leave all unset to disable LLM features (photo analysis won't run).
|
||||||
|
|
||||||
# ANTHROPIC_API_KEY=
|
# ANTHROPIC_API_KEY=
|
||||||
# ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
# ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
|
|
@ -98,32 +72,16 @@ CF_APP_NAME=snipe
|
||||||
# OLLAMA_HOST=http://localhost:11434
|
# OLLAMA_HOST=http://localhost:11434
|
||||||
# OLLAMA_MODEL=llava:7b
|
# 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.
|
# Self-hosted: point at a local cf-orch coordinator if you have one running.
|
||||||
# Cloud (internal): managed coordinator at orch.circuitforge.tech.
|
# Cloud (internal): managed coordinator at orch.circuitforge.tech.
|
||||||
# Leave unset to run vision tasks inline (no VRAM coordination).
|
# Leave unset to run vision tasks inline (no VRAM coordination).
|
||||||
# GPU_SERVER_URL=http://10.1.10.71:7700
|
# CF_ORCH_URL=http://10.1.10.71:7700
|
||||||
#
|
|
||||||
# CF_ORCH_URL is accepted as a backward-compat alias for GPU_SERVER_URL.
|
|
||||||
#
|
#
|
||||||
# cf-orch agent (compose --profile orch) — coordinator URL for the sidecar agent.
|
# 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
|
# 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.
|
|
||||||
# Managed instances: set automatically by cf-orch. Self-hosted: leave unset.
|
|
||||||
# Requires cf-community-postgres container (cf-orch compose stack).
|
|
||||||
# COMMUNITY_DB_URL=postgresql://cf_community:<password>@localhost:5432/cf_community
|
|
||||||
|
|
||||||
# ── In-app feedback (beta) ────────────────────────────────────────────────────
|
# ── In-app feedback (beta) ────────────────────────────────────────────────────
|
||||||
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
||||||
# Leave unset to silently hide the button (demo/offline deployments).
|
# Leave unset to silently hide the button (demo/offline deployments).
|
||||||
|
|
|
||||||
62
.github/workflows/ci.yml
vendored
62
.github/workflows/ci.yml
vendored
|
|
@ -1,62 +0,0 @@
|
||||||
# Snipe CI — runs on GitHub mirror for public credibility badge.
|
|
||||||
# Forgejo (.forgejo/workflows/ci.yml) is the canonical CI — keep these in sync.
|
|
||||||
# No Forgejo-specific secrets used here; circuitforge-core is public on Forgejo.
|
|
||||||
#
|
|
||||||
# Note: playwright browser binaries are not installed here — tests using
|
|
||||||
# headed Chromium (Kasada bypass) are skipped in CI via pytest marks.
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (Python)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install circuitforge-core
|
|
||||||
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -e ".[dev]"
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pytest tests/ -v --tb=short -m "not browser"
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (Vue)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: web
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: web/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx vue-tsc --noEmit
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: npm run test
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -9,5 +9,3 @@ data/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
config/llm.yaml
|
|
||||||
.worktrees/
|
|
||||||
|
|
|
||||||
104
CHANGELOG.md
104
CHANGELOG.md
|
|
@ -6,110 +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
|
|
||||||
|
|
||||||
**Listing detail page** — full trust breakdown for any individual listing (closes placeholder)
|
|
||||||
|
|
||||||
- `ListingView.vue` rewritten from "coming soon" stub into a full trust breakdown view.
|
|
||||||
- SVG trust ring: `stroke-dasharray` fill proportional to composite score (0–100), colour-coded `lv-ring--high/mid/low` (≥80 / 50–79 / <50).
|
|
||||||
- Five-signal breakdown table: account age, feedback count, feedback ratio, price vs. market, category history — each row shows score, max, and a plain-English label.
|
|
||||||
- Red flag badges: hard flags (`.lv-flag--hard`) for `new_account`, `suspicious_price`, `duplicate_photo`, `zero_feedback`, `established_bad_actor`; soft flags (`.lv-flag--soft`) for `scratch_dent_mentioned`, `long_on_market`, `significant_price_drop`, `account_under_30_days`.
|
|
||||||
- Triple Red easter egg: new/under-30-days account + suspicious price + photo/actor/zero-feedback/scratch flag combination triggers pulsing red glow animation.
|
|
||||||
- Partial score warning: `score_is_partial` flag shows `.lv-verdict__partial` notice and "pending" in affected signal rows.
|
|
||||||
- Seller panel: username, account age, feedback count/ratio, category history JSON, inline block-seller form.
|
|
||||||
- Photo carousel: thumbnail strip with keyboard-navigable main image.
|
|
||||||
- Not-found state for direct URL navigation when store is empty.
|
|
||||||
- `getListing(platformListingId)` getter added to search store.
|
|
||||||
- `ListingCard.vue`: "Details" link wired to `/listing/:id` route.
|
|
||||||
|
|
||||||
**Theme override** — user-controlled dark/light/system toggle in Settings
|
|
||||||
|
|
||||||
- `useTheme` composable: module-level `mode` ref, `setMode()` writes `data-theme` attribute + localStorage, `restore()` re-reads localStorage on hard reload.
|
|
||||||
- `theme.css`: explicit `[data-theme="dark"]` and `[data-theme="light"]` attribute selector blocks so user preference beats OS media query. Snipe mode override preserved.
|
|
||||||
- `SettingsView.vue`: new Appearance section with System/Dark/Light segmented button group.
|
|
||||||
- `App.vue`: `restoreTheme()` called in `onMounted` alongside snipe mode restore.
|
|
||||||
|
|
||||||
**Frontend test suite** — 32 Vitest tests, all green
|
|
||||||
|
|
||||||
- `useTheme.test.ts` (7 tests): defaults, setMode, data-theme attribute, localStorage persistence, restore() behaviour.
|
|
||||||
- `searchStore.test.ts` (7 tests): getListing() edge cases, pipe characters in IDs, trustScores/sellers map lookups.
|
|
||||||
- `ListingView.test.ts` (18 tests): not-found state, title/price/score/signals/seller rendering, hard/soft flag badges, no-flags, triple-red class, partial/pending signals, ring colour classes.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `useTheme.restore()` re-reads from localStorage instead of cached module-level ref — prevented correct theme restore after a `setMode()` call in the same JS session.
|
|
||||||
- Landing hero subtitle rewritten with narrative opener ("Seen a listing that looks almost too good to pass up?") — universal framing, no category assumptions.
|
|
||||||
- eBay cancellation callout CTA updated to "Search above to score listings before you commit" — direct action vs. passive notice.
|
|
||||||
- Tile descriptions: concrete examples added ("40% below median", quoted "scratch and dent") for instant domain recognition.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.4.0] — 2026-04-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
**Search with AI** — natural language to eBay search filters (closes #29, Paid+ tier)
|
|
||||||
|
|
||||||
- `QueryTranslator`: sends a free-text prompt to a local LLM (via cf-orch, defaulting to `llama3.1:8b`) with a domain-aware system prompt and eBay Taxonomy category hints. Returns structured `SearchParamsResponse` (keywords, price range, condition, category, sort order, pages).
|
|
||||||
- `EbayCategoryCache`: bootstraps from a seed list; refreshes from the eBay Browse API Taxonomy endpoint on a 7-day TTL. `get_relevant(query)` injects the 10 closest categories into the system prompt to reduce hallucinated filter values.
|
|
||||||
- `POST /api/search/build` — tier-gated endpoint (paid+) that accepts `{"prompt": "..."}` and returns populated `SearchParamsResponse`. Wired to `LLMRouter` via the Peregrine-style shim.
|
|
||||||
- `LLMQueryPanel.vue`: collapsible panel above the search form with a text area, a "Search with AI" button, and an auto-run toggle. A11y (accessibility): `aria-expanded`, `aria-controls`, `aria-live="polite"` on status, keyboard-navigable, `prefers-reduced-motion` guard on collapse animation.
|
|
||||||
- `useLLMQueryBuilder` composable: manages `buildQuery()` state machine (`idle | loading | done | error`), exposes `autoRun` flag, calls `populateFromLLM()` on the search store.
|
|
||||||
- `SettingsView`: new "Search with AI" section with the auto-run toggle persisted to user preferences.
|
|
||||||
- `search.ts`: `populateFromLLM()` merges LLM-returned filters into the store; guards `v-model.number` empty-string edge case (cleared price inputs sent `NaN` to the API).
|
|
||||||
|
|
||||||
**Preferences system**
|
|
||||||
|
|
||||||
- `Store.get_user_preference` / `set_user_preference` / `get_all_preferences`: dot-path read/write over a singleton `user_preferences` JSON row (immutable update pattern via `circuitforge_core.preferences.paths`).
|
|
||||||
- `Store.save_community_signal`: persists trust feedback signals to `community_signals` table.
|
|
||||||
- `preferencesStore` (Pinia): loaded after session bootstrap; `load()` / `set()` / `get()` surface preferences to Vue components.
|
|
||||||
|
|
||||||
**Community module** (closes #31 #32 #33)
|
|
||||||
|
|
||||||
- `corrections` router wired: `POST /api/community/signal` now lands in SQLite `community_signals`.
|
|
||||||
- `COMMUNITY_DB_URL` env var documented in `.env.example`.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `useTrustFeedback`: prefixes fetch URL with `VITE_API_BASE` so feedback signals route correctly under menagerie reverse proxy.
|
|
||||||
- `App.vue`: skip-to-main link moved before `<AppNav>` so keyboard users reach it as the first focusable element (WCAG 2.4.1 bypass-blocks compliance).
|
|
||||||
- `@/` path alias removed from Vue components (Vite config had no alias configured; replaced with relative imports to fix production build).
|
|
||||||
- `search.ts`: LLM-populated filters now sync back into `SearchView` local state so the form reflects the AI-generated values immediately.
|
|
||||||
- Python import ordering pass (isort) across adapters, trust modules, tasks, and test files.
|
|
||||||
|
|
||||||
### Closed
|
|
||||||
|
|
||||||
- `#29` LLM query builder — shipped.
|
|
||||||
- `#31` `#32` `#33` Community corrections router — shipped.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.3.0] — 2026-04-14
|
## [0.3.0] — 2026-04-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ WORKDIR /app
|
||||||
# System deps for Playwright/Chromium
|
# System deps for Playwright/Chromium
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
xvfb \
|
xvfb \
|
||||||
libpq-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install circuitforge-core from sibling directory (compose sets context: ..)
|
# Install circuitforge-core from sibling directory (compose sets context: ..)
|
||||||
|
|
|
||||||
299
README.md
299
README.md
|
|
@ -1,87 +1,27 @@
|
||||||
<!-- Logo coming soon — replace docs/snipe-logo.svg when final icon ships -->
|
# Snipe — Auction Sniping & Listing Intelligence
|
||||||
<div align="center">
|
|
||||||
<img src="docs/snipe-logo.svg" alt="Snipe logo" width="120" />
|
|
||||||
|
|
||||||
# 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)
|
## Quick install (self-hosted)
|
||||||
[]()
|
|
||||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/releases)
|
|
||||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe)
|
|
||||||
[](https://docs.circuitforge.tech/snipe)
|
|
||||||
|
|
||||||
*Part of the Circuit Forge LLC suite — "AI for the tasks the system made hard on purpose."*
|
**Requirements:** Docker with Compose plugin, Git. No API keys needed to get started.
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<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.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One-line install — clones to ~/snipe by default
|
# One-line install — clones to ~/snipe by default
|
||||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh)
|
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**.
|
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/
|
workspace/
|
||||||
|
|
@ -94,88 +34,221 @@ mkdir snipe-workspace && cd snipe-workspace
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
|
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
|
||||||
cd snipe
|
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
|
./manage.sh start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Optional: eBay API credentials
|
### 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)
|
1. Register at [developer.ebay.com](https://developer.ebay.com/my/keys)
|
||||||
2. Copy your Production **App ID** and **Cert ID** into `.env`
|
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 |
|
Snipe has two layers that work together:
|
||||||
|------|-------------|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./manage.sh start # start all services
|
./manage.sh start # start all services
|
||||||
./manage.sh stop # stop
|
./manage.sh stop # stop
|
||||||
./manage.sh restart # restart
|
|
||||||
./manage.sh logs # tail logs
|
./manage.sh logs # tail logs
|
||||||
./manage.sh open # open in browser
|
./manage.sh open # open in browser
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Cloud stack (shared DB, multi-user):
|
||||||
|
```bash
|
||||||
## Stack
|
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
|
||||||
| 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 |
|
## Product code (license key)
|
||||||
|-----------|---------|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
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.
|
"""Cloud session resolution for Snipe FastAPI.
|
||||||
|
|
||||||
Delegates JWT validation, Heimdall provisioning, tier resolution, and guest
|
In local mode (CLOUD_MODE unset/false): all functions return a local CloudUser
|
||||||
session management to circuitforge_core.CloudSessionFactory. Snipe-specific
|
with no auth checks, full tier access, and both DB paths pointing to SNIPE_DB.
|
||||||
CloudUser (shared_db + user_db paths), SessionFeatures, and DB helpers are
|
|
||||||
kept here.
|
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:
|
FastAPI usage:
|
||||||
@app.get("/api/search")
|
@app.get("/api/search")
|
||||||
|
|
@ -16,12 +18,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory
|
import jwt as pyjwt
|
||||||
from fastapi import Depends, HTTPException, Request, Response
|
import requests
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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_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"))
|
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"))
|
_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 ────────────────────────────────────────────────────────────────────
|
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -57,7 +69,6 @@ class SessionFeatures:
|
||||||
photo_analysis: bool
|
photo_analysis: bool
|
||||||
shared_scammer_db: bool
|
shared_scammer_db: bool
|
||||||
shared_image_db: bool
|
shared_image_db: bool
|
||||||
llm_query_builder: bool
|
|
||||||
|
|
||||||
|
|
||||||
def compute_features(tier: str) -> SessionFeatures:
|
def compute_features(tier: str) -> SessionFeatures:
|
||||||
|
|
@ -74,10 +85,100 @@ def compute_features(tier: str) -> SessionFeatures:
|
||||||
photo_analysis=paid_plus,
|
photo_analysis=paid_plus,
|
||||||
shared_scammer_db=paid_plus,
|
shared_scammer_db=paid_plus,
|
||||||
shared_image_db=paid_plus,
|
shared_image_db=paid_plus,
|
||||||
llm_query_builder=paid_plus,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 ───────────────────────────────────────────────────────────
|
# ── DB path helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _shared_db_path() -> Path:
|
def _shared_db_path() -> Path:
|
||||||
|
|
@ -106,25 +207,58 @@ def _anon_db_path() -> Path:
|
||||||
|
|
||||||
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
# ── 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.
|
"""FastAPI dependency — resolves the current user from the request.
|
||||||
|
|
||||||
Delegates auth/tier resolution to cf-core CloudSessionFactory, then maps
|
Local mode: returns a fully-privileged "local" user pointing at SNIPE_DB.
|
||||||
the result to Snipe's CloudUser with shared_db + user_db paths.
|
|
||||||
|
|
||||||
Local mode: fully-privileged "local" user pointing at SNIPE_DB.
|
|
||||||
Cloud mode: validates X-CF-Session JWT, provisions Heimdall license,
|
Cloud mode: validates X-CF-Session JWT, provisions Heimdall license,
|
||||||
resolves tier, returns per-user DB paths.
|
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)
|
if not CLOUD_MODE:
|
||||||
uid, tier = core_user.user_id, core_user.tier
|
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"):
|
cookie_header = request.headers.get("cookie", "")
|
||||||
return CloudUser(user_id=uid, tier=tier, shared_db=_LOCAL_SNIPE_DB, user_db=_LOCAL_SNIPE_DB)
|
raw_header = request.headers.get("x-cf-session", "") or cookie_header
|
||||||
if uid.startswith("anon-"):
|
|
||||||
return CloudUser(user_id=uid, tier=tier, shared_db=_shared_db_path(), user_db=_anon_db_path())
|
if not raw_header:
|
||||||
return CloudUser(user_id=uid, tier=tier, shared_db=_shared_db_path(), user_db=_user_db_path(uid))
|
# 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):
|
def require_tier(min_tier: str):
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,13 @@ from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Request
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
||||||
from cryptography.hazmat.primitives.hashes import SHA1
|
from cryptography.hazmat.primitives.hashes import SHA1
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
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.db.store import Store
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -41,24 +40,6 @@ router = APIRouter()
|
||||||
|
|
||||||
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
_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 ──────────────────────────────────────────────────────────
|
# ── Public-key cache ──────────────────────────────────────────────────────────
|
||||||
# eBay key rotation is rare; 1-hour TTL is appropriate.
|
# eBay key rotation is rare; 1-hour TTL is appropriate.
|
||||||
_KEY_CACHE_TTL = 3600
|
_KEY_CACHE_TTL = 3600
|
||||||
|
|
@ -77,14 +58,7 @@ def _fetch_public_key(kid: str) -> bytes:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
|
|
||||||
key_url = _EBAY_KEY_URL.format(kid=kid)
|
key_url = _EBAY_KEY_URL.format(kid=kid)
|
||||||
headers: dict[str, str] = {}
|
resp = requests.get(key_url, timeout=10)
|
||||||
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)
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500])
|
log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500])
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
@ -94,42 +68,6 @@ def _fetch_public_key(kid: str) -> bytes:
|
||||||
return pem_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 ──────────────────────────────────────────────
|
# ── GET — challenge verification ──────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/ebay/account-deletion")
|
@router.get("/api/ebay/account-deletion")
|
||||||
|
|
|
||||||
1563
api/main.py
1563
api/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,23 +0,0 @@
|
||||||
-- LLM output corrections for SFT training pipeline (cf-core make_corrections_router).
|
|
||||||
-- Stores thumbs-up/down feedback and explicit corrections on LLM-generated content.
|
|
||||||
-- Used once #29 (LLM query builder) ships; table is safe to pre-create now.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS corrections (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id TEXT NOT NULL DEFAULT '',
|
|
||||||
product TEXT NOT NULL,
|
|
||||||
correction_type TEXT NOT NULL,
|
|
||||||
input_text TEXT NOT NULL,
|
|
||||||
original_output TEXT NOT NULL,
|
|
||||||
corrected_output TEXT NOT NULL DEFAULT '',
|
|
||||||
rating TEXT NOT NULL DEFAULT 'down',
|
|
||||||
context TEXT NOT NULL DEFAULT '{}',
|
|
||||||
opted_in INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_corrections_product
|
|
||||||
ON corrections (product);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_corrections_opted_in
|
|
||||||
ON corrections (opted_in);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- app/db/migrations/011_ebay_categories.sql
|
|
||||||
-- eBay category leaf node cache. Refreshed weekly via EbayCategoryCache.refresh().
|
|
||||||
-- Seeded with a small bootstrap table when no eBay API credentials are configured.
|
|
||||||
-- MIT License
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ebay_categories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
category_id TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
full_path TEXT NOT NULL, -- "Consumer Electronics > ... > Leaf Name"
|
|
||||||
is_leaf INTEGER NOT NULL DEFAULT 1, -- SQLite stores bool as int
|
|
||||||
refreshed_at TEXT NOT NULL -- ISO8601 timestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ebay_cat_name
|
|
||||||
ON ebay_categories (name);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Dataclasses for all Snipe domain objects."""
|
"""Dataclasses for all Snipe domain objects."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -81,26 +80,6 @@ class SavedSearch:
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
last_run_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
|
@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."""
|
|
||||||
...
|
|
||||||
252
app/db/store.py
252
app/db/store.py
|
|
@ -1,6 +1,5 @@
|
||||||
"""Thin SQLite read/write layer for all Snipe models."""
|
"""Thin SQLite read/write layer for all Snipe models."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -8,7 +7,7 @@ from typing import Optional
|
||||||
|
|
||||||
from circuitforge_core.db import get_connection, run_migrations
|
from circuitforge_core.db import get_connection, run_migrations
|
||||||
|
|
||||||
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore, WatchAlert
|
from .models import Listing, Seller, TrustScore, MarketComp, SavedSearch, ScammerEntry
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
@ -21,10 +20,6 @@ class Store:
|
||||||
# WAL mode: allows concurrent readers + one writer without blocking
|
# WAL mode: allows concurrent readers + one writer without blocking
|
||||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
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 ---
|
# --- Seller ---
|
||||||
|
|
||||||
def delete_seller_data(self, platform: str, platform_seller_id: str) -> None:
|
def delete_seller_data(self, platform: str, platform_seller_id: str) -> None:
|
||||||
|
|
@ -314,66 +309,15 @@ class Store:
|
||||||
|
|
||||||
def list_saved_searches(self) -> list[SavedSearch]:
|
def list_saved_searches(self) -> list[SavedSearch]:
|
||||||
rows = self._conn.execute(
|
rows = self._conn.execute(
|
||||||
"SELECT name, query, platform, filters_json, id, created_at, last_run_at, "
|
"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 ORDER BY created_at DESC"
|
"FROM saved_searches ORDER BY created_at DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
SavedSearch(
|
SavedSearch(name=r[0], query=r[1], platform=r[2], filters_json=r[3],
|
||||||
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])
|
||||||
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],
|
|
||||||
)
|
|
||||||
for r in rows
|
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:
|
def delete_saved_search(self, saved_id: int) -> None:
|
||||||
self._conn.execute("DELETE FROM saved_searches WHERE id=?", (saved_id,))
|
self._conn.execute("DELETE FROM saved_searches WHERE id=?", (saved_id,))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
@ -385,112 +329,6 @@ class Store:
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
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 ---
|
# --- ScammerBlocklist ---
|
||||||
|
|
||||||
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:
|
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:
|
||||||
|
|
@ -543,88 +381,6 @@ class Store:
|
||||||
for r in rows
|
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(
|
|
||||||
"INSERT INTO community_signals (seller_id, confirmed) VALUES (?, ?)",
|
|
||||||
(seller_id, 1 if confirmed else 0),
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
# --- User Preferences ---
|
|
||||||
|
|
||||||
def get_user_preference(self, path: str, default=None):
|
|
||||||
"""Read a preference value at dot-separated path (e.g. 'affiliate.opt_out').
|
|
||||||
|
|
||||||
Reads from the singleton user_preferences row; returns *default* if the
|
|
||||||
table is empty or the path is not set.
|
|
||||||
"""
|
|
||||||
from circuitforge_core.preferences.paths import get_path
|
|
||||||
row = self._conn.execute(
|
|
||||||
"SELECT prefs_json FROM user_preferences WHERE id=1"
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return default
|
|
||||||
return get_path(json.loads(row[0]), path, default=default)
|
|
||||||
|
|
||||||
def set_user_preference(self, path: str, value) -> None:
|
|
||||||
"""Write *value* at dot-separated path (immutable JSON update).
|
|
||||||
|
|
||||||
Creates the singleton row on first write; merges subsequent updates
|
|
||||||
so sibling paths are preserved.
|
|
||||||
"""
|
|
||||||
from circuitforge_core.preferences.paths import set_path
|
|
||||||
row = self._conn.execute(
|
|
||||||
"SELECT prefs_json FROM user_preferences WHERE id=1"
|
|
||||||
).fetchone()
|
|
||||||
prefs = json.loads(row[0]) if row else {}
|
|
||||||
updated = set_path(prefs, path, value)
|
|
||||||
self._conn.execute(
|
|
||||||
"INSERT INTO user_preferences (id, prefs_json, updated_at) "
|
|
||||||
"VALUES (1, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) "
|
|
||||||
"ON CONFLICT(id) DO UPDATE SET "
|
|
||||||
" prefs_json = excluded.prefs_json, "
|
|
||||||
" updated_at = excluded.updated_at",
|
|
||||||
(json.dumps(updated),),
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
def get_all_preferences(self) -> dict:
|
|
||||||
"""Return all preferences as a plain dict (empty dict if not yet set)."""
|
|
||||||
row = self._conn.execute(
|
|
||||||
"SELECT prefs_json FROM user_preferences WHERE id=1"
|
|
||||||
).fetchone()
|
|
||||||
return json.loads(row[0]) if row else {}
|
|
||||||
|
|
||||||
def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]:
|
def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
"SELECT platform, query_hash, median_price, sample_count, expires_at, id, fetched_at "
|
"SELECT platform, query_hash, median_price, sample_count, expires_at, id, fetched_at "
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# app/llm/__init__.py
|
|
||||||
# BSL 1.1 License
|
|
||||||
from .query_translator import QueryTranslator, QueryTranslatorError, SearchParamsResponse
|
|
||||||
|
|
||||||
__all__ = ["QueryTranslator", "QueryTranslatorError", "SearchParamsResponse"]
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
# app/llm/query_translator.py
|
|
||||||
# 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.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.platforms.ebay.categories import EbayCategoryCache
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryTranslatorError(Exception):
|
|
||||||
"""Raised when the LLM output cannot be parsed into SearchParamsResponse."""
|
|
||||||
def __init__(self, message: str, raw: str = "") -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.raw = raw
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SearchParamsResponse:
|
|
||||||
"""Parsed LLM response — maps 1:1 to the /api/search query parameters."""
|
|
||||||
base_query: str
|
|
||||||
must_include_mode: str # "all" | "any" | "groups"
|
|
||||||
must_include: str # raw filter string
|
|
||||||
must_exclude: str # comma-separated exclusion terms
|
|
||||||
max_price: Optional[float]
|
|
||||||
min_price: Optional[float]
|
|
||||||
condition: list[str] # subset of ["new", "used", "for_parts"]
|
|
||||||
category_id: Optional[str] # eBay category ID string, or None
|
|
||||||
explanation: str # one-sentence plain-language summary
|
|
||||||
|
|
||||||
|
|
||||||
_VALID_MODES = {"all", "any", "groups"}
|
|
||||||
_VALID_CONDITIONS = {"new", "used", "for_parts"}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_response(raw: str) -> SearchParamsResponse:
|
|
||||||
"""Parse the LLM's raw text output into a SearchParamsResponse.
|
|
||||||
|
|
||||||
Raises QueryTranslatorError if the JSON is malformed or required fields
|
|
||||||
are missing.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(raw.strip())
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise QueryTranslatorError(f"LLM returned unparseable JSON: {exc}", raw=raw) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
base_query = str(data["base_query"]).strip()
|
|
||||||
if not base_query:
|
|
||||||
raise KeyError("base_query is empty")
|
|
||||||
must_include_mode = str(data.get("must_include_mode", "all"))
|
|
||||||
if must_include_mode not in _VALID_MODES:
|
|
||||||
must_include_mode = "all"
|
|
||||||
must_include = str(data.get("must_include", ""))
|
|
||||||
must_exclude = str(data.get("must_exclude", ""))
|
|
||||||
max_price = float(data["max_price"]) if data.get("max_price") is not None else None
|
|
||||||
min_price = float(data["min_price"]) if data.get("min_price") is not None else None
|
|
||||||
raw_conditions = data.get("condition", [])
|
|
||||||
condition = [c for c in raw_conditions if c in _VALID_CONDITIONS]
|
|
||||||
category_id = str(data["category_id"]) if data.get("category_id") else None
|
|
||||||
explanation = str(data.get("explanation", "")).strip()
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise QueryTranslatorError(
|
|
||||||
f"LLM response missing or invalid field: {exc}", raw=raw
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return SearchParamsResponse(
|
|
||||||
base_query=base_query,
|
|
||||||
must_include_mode=must_include_mode,
|
|
||||||
must_include=must_include,
|
|
||||||
must_exclude=must_exclude,
|
|
||||||
max_price=max_price,
|
|
||||||
min_price=min_price,
|
|
||||||
condition=condition,
|
|
||||||
category_id=category_id,
|
|
||||||
explanation=explanation,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── System prompt template ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_SYSTEM_PROMPT_TEMPLATE = """\
|
|
||||||
You are a search assistant for Snipe, an eBay listing intelligence tool.
|
|
||||||
Your job is to translate a natural-language description of what someone is looking for
|
|
||||||
into a structured eBay search configuration.
|
|
||||||
|
|
||||||
Return ONLY a JSON object with these exact fields — no preamble, no markdown, no extra keys:
|
|
||||||
base_query (string) Primary search term, short — e.g. "RTX 3080", "vintage Leica"
|
|
||||||
must_include_mode (string) One of: "all" (AND), "any" (OR), "groups" (CNF: pipe=OR within group, comma=AND between groups)
|
|
||||||
must_include (string) Filter string per mode — leave blank if nothing to filter
|
|
||||||
must_exclude (string) Comma-separated terms to exclude — e.g. "mining,for parts,broken"
|
|
||||||
max_price (number|null) Maximum price in USD, or null
|
|
||||||
min_price (number|null) Minimum price in USD, or null
|
|
||||||
condition (array) Any of: "new", "used", "for_parts" — empty array means any condition
|
|
||||||
category_id (string|null) eBay category ID from the list below, or null if no match
|
|
||||||
explanation (string) One plain sentence summarizing what you built
|
|
||||||
|
|
||||||
eBay "groups" mode syntax example: to find a GPU that is BOTH (nvidia OR amd) AND (16gb OR 8gb):
|
|
||||||
must_include_mode: "groups"
|
|
||||||
must_include: "nvidia|amd, 16gb|8gb"
|
|
||||||
|
|
||||||
Phrase "like new", "open box", "refurbished" -> condition: ["used"]
|
|
||||||
Phrase "broken", "for parts", "not working" -> condition: ["for_parts"]
|
|
||||||
If unsure about condition, use an empty array.
|
|
||||||
|
|
||||||
Available eBay categories (use category_id verbatim if one fits — otherwise omit):
|
|
||||||
{category_hints}
|
|
||||||
|
|
||||||
If none match, omit category_id (set to null). Respond with valid JSON only. No commentary outside the JSON object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# ── QueryTranslator ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class QueryTranslator:
|
|
||||||
"""Translates natural-language search descriptions into SearchParamsResponse.
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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")
|
|
||||||
self._cache = category_cache
|
|
||||||
self._cforch_url = cforch_url
|
|
||||||
self._llm_router = llm_router
|
|
||||||
|
|
||||||
def translate(self, natural_language: str) -> SearchParamsResponse:
|
|
||||||
"""Translate a natural-language query into a SearchParamsResponse.
|
|
||||||
|
|
||||||
Raises QueryTranslatorError if the LLM fails or returns bad JSON.
|
|
||||||
"""
|
|
||||||
# Extract up to 10 keywords for category hint lookup
|
|
||||||
keywords = [w for w in natural_language.split()[:10] if len(w) > 2]
|
|
||||||
hints = self._cache.get_relevant(keywords, limit=30)
|
|
||||||
if not hints:
|
|
||||||
hints = self._cache.get_all_for_prompt(limit=40)
|
|
||||||
|
|
||||||
if hints:
|
|
||||||
category_hints = "\n".join(f"{cid}: {path}" for cid, path in hints)
|
|
||||||
else:
|
|
||||||
category_hints = "(no categories cached — omit category_id)"
|
|
||||||
|
|
||||||
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
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# app/llm/router.py
|
|
||||||
# BSL 1.1 License
|
|
||||||
"""
|
|
||||||
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)
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
|
|
||||||
|
|
||||||
_REPO_CONFIG = Path(__file__).parent.parent.parent / "config" / "llm.yaml"
|
|
||||||
_USER_CONFIG = Path.home() / ".config" / "circuitforge" / "llm.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
class LLMRouter(_CoreLLMRouter):
|
|
||||||
"""Snipe-specific LLMRouter with tri-level config resolution.
|
|
||||||
|
|
||||||
Explicit ``config_path`` bypasses the lookup (useful in tests).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, config_path: Path | None = None) -> None:
|
|
||||||
if config_path is not None:
|
|
||||||
super().__init__(config_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
if _REPO_CONFIG.exists():
|
|
||||||
super().__init__(_REPO_CONFIG)
|
|
||||||
elif _USER_CONFIG.exists():
|
|
||||||
super().__init__(_USER_CONFIG)
|
|
||||||
else:
|
|
||||||
# No yaml — let circuitforge-core env-var auto-config handle it.
|
|
||||||
super().__init__()
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
"""PlatformAdapter abstract base and shared types."""
|
"""PlatformAdapter abstract base and shared types."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Listing, Seller
|
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
|
@dataclass
|
||||||
class SearchFilters:
|
class SearchFilters:
|
||||||
|
|
@ -22,8 +16,6 @@ class SearchFilters:
|
||||||
must_include: list[str] = field(default_factory=list) # client-side title filter
|
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
|
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)
|
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):
|
class PlatformAdapter(ABC):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
"""eBay Browse + Trading API adapter."""
|
"""eBay Browse API adapter."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -21,8 +18,8 @@ _SHOPPING_API_MAX_PER_SEARCH = 5 # sellers enriched per search call
|
||||||
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
|
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
|
||||||
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
|
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
|
||||||
|
|
||||||
from app.db.models import Listing, MarketComp, Seller
|
from app.db.models import Listing, Seller, MarketComp
|
||||||
from app.db.protocol import SharedTableProtocol
|
from app.db.store import Store
|
||||||
from app.platforms import PlatformAdapter, SearchFilters
|
from app.platforms import PlatformAdapter, SearchFilters
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
||||||
|
|
@ -67,7 +64,7 @@ BROWSE_BASE = {
|
||||||
|
|
||||||
|
|
||||||
class EbayAdapter(PlatformAdapter):
|
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._tokens = token_manager
|
||||||
self._store = shared_store
|
self._store = shared_store
|
||||||
self._env = env
|
self._env = env
|
||||||
|
|
@ -211,70 +208,6 @@ class EbayAdapter(PlatformAdapter):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug("Shopping API enrich failed for %s: %s", username, 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]:
|
def get_seller(self, seller_platform_id: str) -> Optional[Seller]:
|
||||||
cached = self._store.get_seller("ebay", seller_platform_id)
|
cached = self._store.get_seller("ebay", seller_platform_id)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
"""eBay OAuth2 client credentials token manager."""
|
"""eBay OAuth2 client credentials token manager."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
EBAY_OAUTH_URLS = {
|
EBAY_OAUTH_URLS = {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
# app/platforms/ebay/categories.py
|
|
||||||
# MIT License
|
|
||||||
"""eBay category cache — fetches leaf categories from the Taxonomy API and stores them
|
|
||||||
in the local SQLite DB for injection into LLM query-builder prompts.
|
|
||||||
|
|
||||||
Refreshed weekly. Falls back to a hardcoded bootstrap table when no eBay API
|
|
||||||
credentials are configured (scraper-only users still get usable category hints).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Bootstrap table — common categories for self-hosters without eBay API credentials.
|
|
||||||
# category_id values are stable eBay leaf IDs (US marketplace, as of 2026).
|
|
||||||
_BOOTSTRAP_CATEGORIES: list[tuple[str, str, str]] = [
|
|
||||||
("27386", "Graphics Cards", "Consumer Electronics > Computers > Components > Graphics/Video Cards"),
|
|
||||||
("164", "CPUs/Processors", "Consumer Electronics > Computers > Components > CPUs/Processors"),
|
|
||||||
("170083","RAM", "Consumer Electronics > Computers > Components > Memory (RAM)"),
|
|
||||||
("175669","Solid State Drives", "Consumer Electronics > Computers > Components > Drives > Solid State Drives"),
|
|
||||||
("177089","Hard Drives", "Consumer Electronics > Computers > Components > Drives > Hard Drives"),
|
|
||||||
("179142","Laptops", "Consumer Electronics > Computers > Laptops & Netbooks"),
|
|
||||||
("171957","Desktop Computers", "Consumer Electronics > Computers > Desktops & All-in-Ones"),
|
|
||||||
("293", "Consumer Electronics","Consumer Electronics"),
|
|
||||||
("625", "Cameras", "Consumer Electronics > Cameras & Photography > Digital Cameras"),
|
|
||||||
("15052", "Vintage Cameras", "Consumer Electronics > Cameras & Photography > Vintage Movie Cameras"),
|
|
||||||
("11724", "Audio Equipment", "Consumer Electronics > TV, Video & Home Audio > Home Audio"),
|
|
||||||
("3676", "Vinyl Records", "Music > Records"),
|
|
||||||
("870", "Musical Instruments","Musical Instruments & Gear"),
|
|
||||||
("31388", "Video Game Consoles","Video Games & Consoles > Video Game Consoles"),
|
|
||||||
("139971","Video Games", "Video Games & Consoles > Video Games"),
|
|
||||||
("139973","Video Game Accessories", "Video Games & Consoles > Video Game Accessories"),
|
|
||||||
("14308", "Networking Gear", "Computers/Tablets & Networking > Home Networking & Connectivity"),
|
|
||||||
("182062","Smartphones", "Cell Phones & Smartphones"),
|
|
||||||
("9394", "Tablets", "Computers/Tablets & Networking > Tablets & eBook Readers"),
|
|
||||||
("11233", "Collectibles", "Collectibles"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class EbayCategoryCache:
|
|
||||||
"""Caches eBay leaf categories in SQLite for LLM prompt injection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: An open sqlite3.Connection with migration 011 already applied.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
||||||
self._conn = conn
|
|
||||||
|
|
||||||
def is_stale(self, max_age_days: int = 7) -> bool:
|
|
||||||
"""Return True if the cache is empty or all entries are older than max_age_days."""
|
|
||||||
cur = self._conn.execute("SELECT MAX(refreshed_at) FROM ebay_categories")
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or not row[0]:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
latest = datetime.fromisoformat(row[0])
|
|
||||||
if latest.tzinfo is None:
|
|
||||||
latest = latest.replace(tzinfo=timezone.utc)
|
|
||||||
return datetime.now(timezone.utc) - latest > timedelta(days=max_age_days)
|
|
||||||
except ValueError:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _seed_bootstrap(self) -> None:
|
|
||||||
"""Insert the hardcoded bootstrap categories. Idempotent (ON CONFLICT IGNORE)."""
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
self._conn.executemany(
|
|
||||||
"INSERT OR IGNORE INTO ebay_categories"
|
|
||||||
" (category_id, name, full_path, is_leaf, refreshed_at)"
|
|
||||||
" VALUES (?, ?, ?, 1, ?)",
|
|
||||||
[(cid, name, path, now) for cid, name, path in _BOOTSTRAP_CATEGORIES],
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
log.info("EbayCategoryCache: seeded %d bootstrap categories.", len(_BOOTSTRAP_CATEGORIES))
|
|
||||||
|
|
||||||
def get_relevant(
|
|
||||||
self,
|
|
||||||
keywords: list[str],
|
|
||||||
limit: int = 30,
|
|
||||||
) -> list[tuple[str, str]]:
|
|
||||||
"""Return (category_id, full_path) pairs matching any keyword.
|
|
||||||
|
|
||||||
Matches against both name and full_path (case-insensitive LIKE).
|
|
||||||
Returns at most `limit` rows.
|
|
||||||
"""
|
|
||||||
if not keywords:
|
|
||||||
return []
|
|
||||||
conditions = " OR ".join(
|
|
||||||
"LOWER(name) LIKE ? OR LOWER(full_path) LIKE ?" for _ in keywords
|
|
||||||
)
|
|
||||||
params: list[str] = []
|
|
||||||
for kw in keywords:
|
|
||||||
like = f"%{kw.lower()}%"
|
|
||||||
params.extend([like, like])
|
|
||||||
params.append(limit)
|
|
||||||
cur = self._conn.execute(
|
|
||||||
f"SELECT category_id, full_path FROM ebay_categories"
|
|
||||||
f" WHERE {conditions} ORDER BY name LIMIT ?",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
return [(row[0], row[1]) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
def get_all_for_prompt(self, limit: int = 80) -> list[tuple[str, str]]:
|
|
||||||
"""Return up to `limit` (category_id, full_path) pairs, sorted by name.
|
|
||||||
|
|
||||||
Used when no keyword context is available.
|
|
||||||
"""
|
|
||||||
cur = self._conn.execute(
|
|
||||||
"SELECT category_id, full_path FROM ebay_categories ORDER BY name LIMIT ?",
|
|
||||||
(limit,),
|
|
||||||
)
|
|
||||||
return [(row[0], row[1]) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
def refresh(
|
|
||||||
self,
|
|
||||||
token_manager: Optional["EbayTokenManager"] = None,
|
|
||||||
community_store: Optional[object] = None,
|
|
||||||
) -> int:
|
|
||||||
"""Fetch the eBay category tree and upsert leaf nodes into SQLite.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token_manager: An `EbayTokenManager` instance for the Taxonomy API.
|
|
||||||
If None, falls back to seeding the hardcoded bootstrap table.
|
|
||||||
community_store: Optional SnipeCommunityStore instance.
|
|
||||||
If provided and token_manager is set, publish leaves after a successful
|
|
||||||
Taxonomy API fetch.
|
|
||||||
If provided and token_manager is None, fetch from community before
|
|
||||||
falling back to the hardcoded bootstrap (requires >= 10 rows).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of leaf categories stored.
|
|
||||||
"""
|
|
||||||
if token_manager is None:
|
|
||||||
# Try community store first
|
|
||||||
if community_store is not None:
|
|
||||||
try:
|
|
||||||
community_cats = community_store.fetch_categories()
|
|
||||||
if len(community_cats) >= 10:
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
self._conn.executemany(
|
|
||||||
"INSERT OR REPLACE INTO ebay_categories"
|
|
||||||
" (category_id, name, full_path, is_leaf, refreshed_at)"
|
|
||||||
" VALUES (?, ?, ?, 1, ?)",
|
|
||||||
[(cid, name, path, now) for cid, name, path in community_cats],
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
log.info(
|
|
||||||
"EbayCategoryCache: loaded %d categories from community store.",
|
|
||||||
len(community_cats),
|
|
||||||
)
|
|
||||||
return len(community_cats)
|
|
||||||
log.info(
|
|
||||||
"EbayCategoryCache: community store has %d categories (< 10) — falling back to bootstrap.",
|
|
||||||
len(community_cats),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
log.warning(
|
|
||||||
"EbayCategoryCache: community store fetch failed — falling back to bootstrap.",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
self._seed_bootstrap()
|
|
||||||
cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories")
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
|
|
||||||
try:
|
|
||||||
token = token_manager.get_token()
|
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
# Step 1: get default tree ID for EBAY_US
|
|
||||||
id_resp = requests.get(
|
|
||||||
"https://api.ebay.com/commerce/taxonomy/v1/get_default_category_tree_id",
|
|
||||||
params={"marketplace_id": "EBAY_US"},
|
|
||||||
headers=headers,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
id_resp.raise_for_status()
|
|
||||||
tree_id = id_resp.json()["categoryTreeId"]
|
|
||||||
|
|
||||||
# Step 2: fetch full tree (large response — may take several seconds)
|
|
||||||
tree_resp = requests.get(
|
|
||||||
f"https://api.ebay.com/commerce/taxonomy/v1/category_tree/{tree_id}",
|
|
||||||
headers=headers,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
tree_resp.raise_for_status()
|
|
||||||
tree = tree_resp.json()
|
|
||||||
|
|
||||||
leaves: list[tuple[str, str, str]] = []
|
|
||||||
_extract_leaves(tree["rootCategoryNode"], path="", leaves=leaves)
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
self._conn.executemany(
|
|
||||||
"INSERT OR REPLACE INTO ebay_categories"
|
|
||||||
" (category_id, name, full_path, is_leaf, refreshed_at)"
|
|
||||||
" VALUES (?, ?, ?, 1, ?)",
|
|
||||||
[(cid, name, path, now) for cid, name, path in leaves],
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
log.info(
|
|
||||||
"EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.",
|
|
||||||
len(leaves),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Publish to community store if available
|
|
||||||
if community_store is not None:
|
|
||||||
try:
|
|
||||||
community_store.publish_categories(leaves)
|
|
||||||
except Exception:
|
|
||||||
log.warning(
|
|
||||||
"EbayCategoryCache: failed to publish categories to community store.",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return len(leaves)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
log.warning(
|
|
||||||
"EbayCategoryCache: Taxonomy API refresh failed — falling back to bootstrap.",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
self._seed_bootstrap()
|
|
||||||
cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories")
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_leaves(
|
|
||||||
node: dict,
|
|
||||||
path: str,
|
|
||||||
leaves: list[tuple[str, str, str]],
|
|
||||||
) -> None:
|
|
||||||
"""Recursively walk the eBay category tree, collecting leaf node tuples.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
node: A categoryTreeNode dict from the eBay Taxonomy API response.
|
|
||||||
path: The ancestor breadcrumb, e.g. "Consumer Electronics > Computers".
|
|
||||||
leaves: Accumulator list of (category_id, name, full_path) tuples.
|
|
||||||
"""
|
|
||||||
cat = node["category"]
|
|
||||||
cat_id: str = cat["categoryId"]
|
|
||||||
cat_name: str = cat["categoryName"]
|
|
||||||
full_path = f"{path} > {cat_name}" if path else cat_name
|
|
||||||
|
|
||||||
if node.get("leafCategoryTreeNode", False):
|
|
||||||
leaves.append((cat_id, cat_name, full_path))
|
|
||||||
return # leaf — no children to recurse into
|
|
||||||
|
|
||||||
for child in node.get("childCategoryTreeNodes", []):
|
|
||||||
_extract_leaves(child, full_path, leaves)
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
"""Convert raw eBay API responses into Snipe domain objects."""
|
"""Convert raw eBay API responses into Snipe domain objects."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Listing, Seller
|
from app.db.models import Listing, Seller
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ log = logging.getLogger(__name__)
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from app.db.models import Listing, MarketComp, Seller
|
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 import PlatformAdapter, SearchFilters
|
||||||
|
|
||||||
EBAY_SEARCH_URL = "https://www.ebay.com/sch/i.html"
|
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.
|
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._store = shared_store
|
||||||
self._delay = delay
|
self._delay = delay
|
||||||
|
|
||||||
def _fetch_url(self, url: str) -> str:
|
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).
|
Shared by both search (_get) and BTF item-page enrichment (_fetch_item_html).
|
||||||
Results cached for _HTML_CACHE_TTL seconds.
|
Results cached for _HTML_CACHE_TTL seconds.
|
||||||
|
|
@ -300,8 +300,44 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
if cached and time.time() < cached[1]:
|
if cached and time.time() < cached[1]:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
|
|
||||||
from app.platforms.ebay.browser_pool import get_pool # noqa: PLC0415 — lazy import
|
time.sleep(self._delay)
|
||||||
html = get_pool().fetch_html(url, delay=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)
|
_html_cache[url] = (html, time.time() + _HTML_CACHE_TTL)
|
||||||
return html
|
return html
|
||||||
|
|
@ -374,6 +410,8 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
Does not raise — failures per-seller are silently skipped so the main
|
Does not raise — failures per-seller are silently skipped so the main
|
||||||
search response is never blocked.
|
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:
|
def _enrich_one(item: tuple[str, str]) -> None:
|
||||||
seller_id, listing_id = item
|
seller_id, listing_id = item
|
||||||
try:
|
try:
|
||||||
|
|
@ -386,7 +424,7 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
)
|
)
|
||||||
if age_days is None and fb_count is None:
|
if age_days is None and fb_count is None:
|
||||||
return # nothing new to write
|
return # nothing new to write
|
||||||
thread_store = self._store.clone()
|
thread_store = Store(db_path)
|
||||||
seller = thread_store.get_seller("ebay", seller_id)
|
seller = thread_store.get_seller("ebay", seller_id)
|
||||||
if not seller:
|
if not seller:
|
||||||
log.warning("BTF enrich: seller %s not found in DB", seller_id)
|
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
|
trust_photo_analysis — download primary photo, run vision LLM, write
|
||||||
result to trust_scores.photo_analysis_json (Paid tier).
|
result to trust_scores.photo_analysis_json (Paid tier).
|
||||||
|
|
||||||
Image assessment routing:
|
Prompt note: The vision prompt is a functional first pass. Tune against real
|
||||||
Cloud (GPU_SERVER_URL set): allocates via cf-orch task endpoint
|
eBay listings before GA — specifically stock-photo vs genuine-product distinction
|
||||||
product=snipe, task=image_assessment.
|
and the damage vocabulary.
|
||||||
Local (no GPU_SERVER_URL) or TaskNotFound fallback: uses LLMRouter
|
|
||||||
with a vision-capable local backend (moondream2, llava, etc.).
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
|
||||||
import requests
|
import requests
|
||||||
from circuitforge_core.db import get_connection
|
from circuitforge_core.db import get_connection
|
||||||
|
from circuitforge_core.llm import LLMRouter
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
LLM_TASK_TYPES: frozenset[str] = frozenset({"trust_photo_analysis"})
|
LLM_TASK_TYPES: frozenset[str] = frozenset({"trust_photo_analysis"})
|
||||||
|
|
||||||
VRAM_BUDGETS: dict[str, float] = {
|
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 = (
|
_VISION_SYSTEM_PROMPT = (
|
||||||
|
|
@ -53,7 +51,8 @@ def insert_task(
|
||||||
) -> tuple[int, bool]:
|
) -> tuple[int, bool]:
|
||||||
"""Insert a background task if no identical task is already in-flight.
|
"""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 = get_connection(db_path)
|
||||||
conn.row_factory = __import__("sqlite3").Row
|
conn.row_factory = __import__("sqlite3").Row
|
||||||
|
|
@ -121,26 +120,32 @@ def _run_trust_photo_analysis(
|
||||||
p = json.loads(params or "{}")
|
p = json.loads(params or "{}")
|
||||||
photo_url = p.get("photo_url", "")
|
photo_url = p.get("photo_url", "")
|
||||||
listing_title = p.get("listing_title", "")
|
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)))
|
result_db = Path(p.get("user_db", str(db_path)))
|
||||||
|
|
||||||
if not photo_url:
|
if not photo_url:
|
||||||
raise ValueError("trust_photo_analysis: 'photo_url' is required in params")
|
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 = requests.get(photo_url, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
image_b64 = base64.b64encode(resp.content).decode()
|
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:
|
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")
|
# Call LLMRouter with vision capability
|
||||||
if cforch_url:
|
router = LLMRouter()
|
||||||
raw = _assess_via_orch(cforch_url, image_data_url, user_prompt)
|
raw = router.complete(
|
||||||
else:
|
user_prompt,
|
||||||
raw = _assess_via_local_llm(image_b64, user_prompt)
|
system=_VISION_SYSTEM_PROMPT,
|
||||||
|
images=[image_b64],
|
||||||
|
max_tokens=128,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse — be lenient: strip markdown fences if present
|
||||||
try:
|
try:
|
||||||
cleaned = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
cleaned = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||||
analysis = json.loads(cleaned)
|
analysis = json.loads(cleaned)
|
||||||
|
|
@ -163,54 +168,3 @@ def _run_trust_photo_analysis(
|
||||||
analysis.get("visible_damage"),
|
analysis.get("visible_damage"),
|
||||||
analysis.get("confidence"),
|
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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from circuitforge_core.tasks.scheduler import (
|
||||||
)
|
)
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
get_scheduler as _base_get_scheduler,
|
get_scheduler as _base_get_scheduler,
|
||||||
reset_scheduler, # re-export for lifespan teardown
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ FEATURES: dict[str, str] = {
|
||||||
"reverse_image_search": "paid",
|
"reverse_image_search": "paid",
|
||||||
"ebay_oauth": "paid", # full trust scores via eBay Trading API
|
"ebay_oauth": "paid", # full trust scores via eBay Trading API
|
||||||
"background_monitoring": "paid", # limited at Paid; see LIMITS below
|
"background_monitoring": "paid", # limited at Paid; see LIMITS below
|
||||||
"llm_query_builder": "paid", # inline natural-language → filter translator
|
|
||||||
|
|
||||||
# Premium tier
|
# Premium tier
|
||||||
"auto_bidding": "premium",
|
"auto_bidding": "premium",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import hashlib
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore
|
from app.db.models import Listing, TrustScore
|
||||||
from app.db.protocol import SharedTableProtocol
|
from app.db.store import Store
|
||||||
|
|
||||||
from .aggregator import Aggregator
|
from .aggregator import Aggregator
|
||||||
from .metadata import MetadataScorer
|
from .metadata import MetadataScorer
|
||||||
|
|
@ -12,7 +12,7 @@ from .photo import PhotoScorer
|
||||||
class TrustScorer:
|
class TrustScorer:
|
||||||
"""Orchestrates metadata + photo scoring for a batch of listings."""
|
"""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._store = shared_store
|
||||||
self._meta = MetadataScorer()
|
self._meta = MetadataScorer()
|
||||||
self._photo = PhotoScorer()
|
self._photo = PhotoScorer()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Seller, TrustScore
|
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_MIN_COUNT = 20
|
||||||
HARD_FILTER_BAD_RATIO_THRESHOLD = 0.80
|
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.
|
# Sellers above this feedback count are treated as established retailers.
|
||||||
# Stock photo reuse (duplicate_photo) is suppressed for them — large retailers
|
# Stock photo reuse (duplicate_photo) is suppressed for them — large retailers
|
||||||
# legitimately share manufacturer images across many listings.
|
# 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"))
|
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
# Normalize to naive UTC so both paths (timezone-aware ISO and SQLite
|
# Normalize to naive UTC so both paths (timezone-aware ISO and SQLite
|
||||||
# CURRENT_TIMESTAMP naive strings) compare correctly.
|
# CURRENT_TIMESTAMP naive strings) compare correctly.
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is not None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=None)
|
||||||
return (datetime.now(timezone.utc) - dt).days
|
return (datetime.utcnow() - dt).days
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -126,23 +117,11 @@ class Aggregator:
|
||||||
# Hard filters
|
# Hard filters
|
||||||
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
|
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
|
||||||
red_flags.append("new_account")
|
red_flags.append("new_account")
|
||||||
if seller and seller.feedback_ratio == 0.0 and seller.feedback_count > 0:
|
if seller and (
|
||||||
# 12-month ratio missing from page — returning seller or buyer-only account.
|
seller.feedback_ratio < HARD_FILTER_BAD_RATIO_THRESHOLD
|
||||||
# Score will be partial (metadata._feedback_ratio returns None). Soft flag
|
and seller.feedback_count > HARD_FILTER_BAD_RATIO_MIN_COUNT
|
||||||
# only: do NOT fire established_bad_actor on what is likely missing data.
|
):
|
||||||
red_flags.append("no_recent_seller_data")
|
red_flags.append("established_bad_actor")
|
||||||
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_count == 0:
|
if seller and seller.feedback_count == 0:
|
||||||
red_flags.append("zero_feedback")
|
red_flags.append("zero_feedback")
|
||||||
# Zero feedback is a deliberate signal, not missing data — cap composite score
|
# Zero feedback is a deliberate signal, not missing data — cap composite score
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
"""Five metadata trust signals, each scored 0–20."""
|
"""Five metadata trust signals, each scored 0–20."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Seller
|
from app.db.models import Seller
|
||||||
|
|
||||||
ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"}
|
ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"}
|
||||||
|
|
@ -44,13 +42,7 @@ class MetadataScorer:
|
||||||
if count < 200: return 15
|
if count < 200: return 15
|
||||||
return 20
|
return 20
|
||||||
|
|
||||||
def _feedback_ratio(self, ratio: float, count: int) -> Optional[int]:
|
def _feedback_ratio(self, ratio: float, count: int) -> 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
|
|
||||||
if ratio < 0.80 and count > 20: return 0
|
if ratio < 0.80 and count > 20: return 0
|
||||||
if ratio < 0.90: return 5
|
if ratio < 0.90: return 5
|
||||||
if ratio < 0.95: return 10
|
if ratio < 0.95: return 10
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
"""Perceptual hash deduplication within a result set (free tier, v0.1)."""
|
"""Perceptual hash deduplication within a result set (free tier, v0.1)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import io
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,19 @@
|
||||||
"""Main search + results page."""
|
"""Main search + results page."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from circuitforge_core.config import load_env
|
from circuitforge_core.config import load_env
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.platforms import PlatformAdapter, SearchFilters
|
from app.platforms import PlatformAdapter, SearchFilters
|
||||||
from app.trust import TrustScorer
|
from app.trust import TrustScorer
|
||||||
from app.ui.components.easter_eggs import (
|
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
|
||||||
auction_hours_remaining,
|
|
||||||
check_snipe_mode,
|
|
||||||
inject_steal_css,
|
|
||||||
render_snipe_mode_banner,
|
|
||||||
)
|
|
||||||
from app.ui.components.filters import FilterState, build_filter_options, render_filter_sidebar
|
|
||||||
from app.ui.components.listing_row import render_listing_row
|
from app.ui.components.listing_row import render_listing_row
|
||||||
|
from app.ui.components.easter_eggs import (
|
||||||
|
inject_steal_css, check_snipe_mode, render_snipe_mode_banner,
|
||||||
|
auction_hours_remaining,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import streamlit as st
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore
|
from app.db.models import Listing, TrustScore
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. Konami → Snipe Mode
|
# 1. Konami → Snipe Mode
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
|
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore
|
from app.db.models import Listing, TrustScore
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
"""Render a single listing row with trust score, badges, and error states."""
|
"""Render a single listing row with trust score, badges, and error states."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from app.db.models import Listing, Seller, TrustScore
|
from app.db.models import Listing, TrustScore, Seller
|
||||||
from app.ui.components.easter_eggs import (
|
from app.ui.components.easter_eggs import (
|
||||||
auction_hours_remaining,
|
is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining,
|
||||||
is_steal,
|
|
||||||
render_auction_notice,
|
|
||||||
render_steal_banner,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"""First-run wizard: collect eBay credentials and write .env."""
|
"""First-run wizard: collect eBay credentials and write .env."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from circuitforge_core.wizard import BaseWizard
|
from circuitforge_core.wizard import BaseWizard
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,9 @@ services:
|
||||||
CLOUD_MODE: "true"
|
CLOUD_MODE: "true"
|
||||||
CLOUD_DATA_ROOT: /devl/snipe-cloud-data
|
CLOUD_DATA_ROOT: /devl/snipe-cloud-data
|
||||||
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env (never commit)
|
# 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.
|
|
||||||
# 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
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
# No network_mode: host — isolated on snipe-cloud-net; nginx reaches it via 'api:8510'
|
# No network_mode: host — isolated on snipe-cloud-net; nginx reaches it via 'api:8510'
|
||||||
volumes:
|
volumes:
|
||||||
- /devl/snipe-cloud-data:/devl/snipe-cloud-data
|
- /devl/snipe-cloud-data:/devl/snipe-cloud-data
|
||||||
- ./config/llm.cloud.yaml:/app/snipe/config/llm.yaml:ro
|
|
||||||
networks:
|
networks:
|
||||||
- snipe-cloud-net
|
- snipe-cloud-net
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- RELOAD=true
|
- RELOAD=true
|
||||||
# Point the LLM/vision task scheduler at the local cf-orch coordinator.
|
# 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).
|
# Only has effect when CF_ORCH_URL is set (uncomment in .env, or set inline).
|
||||||
# - GPU_SERVER_URL=http://10.1.10.71:7700
|
# - CF_ORCH_URL=http://10.1.10.71:7700
|
||||||
|
|
||||||
# cf-orch agent — routes trust_photo_analysis vision tasks to the GPU coordinator.
|
# cf-orch agent — routes trust_photo_analysis vision tasks to the GPU coordinator.
|
||||||
# Only starts when you pass --profile orch:
|
# Only starts when you pass --profile orch:
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# config/llm.cloud.yaml
|
|
||||||
# Snipe — LLM config for the managed cloud instance (menagerie)
|
|
||||||
#
|
|
||||||
# Mounted read-only into the cloud API container at /app/config/llm.yaml
|
|
||||||
# (see compose.cloud.yml). Personal fine-tunes and local-only backends
|
|
||||||
# (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;
|
|
||||||
# if cf-orch is unreachable the backend falls back to its static base_url.
|
|
||||||
#
|
|
||||||
# Model choice for query builder: llama3.1:8b
|
|
||||||
# - Reliable instruction following and JSON output
|
|
||||||
# - No creative fine-tuning drift (unlike writer models in the pool)
|
|
||||||
# - Fits comfortably in 8 GB VRAM alongside other services
|
|
||||||
|
|
||||||
backends:
|
|
||||||
ollama:
|
|
||||||
type: openai_compat
|
|
||||||
base_url: http://host.docker.internal:11434/v1
|
|
||||||
api_key: ollama
|
|
||||||
model: llama3.1:8b
|
|
||||||
enabled: true
|
|
||||||
supports_images: false
|
|
||||||
cf_orch:
|
|
||||||
service: ollama
|
|
||||||
ttl_s: 300
|
|
||||||
|
|
||||||
anthropic:
|
|
||||||
type: anthropic
|
|
||||||
api_key_env: ANTHROPIC_API_KEY
|
|
||||||
model: claude-haiku-4-5-20251001
|
|
||||||
enabled: false
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
fallback_order:
|
|
||||||
- ollama
|
|
||||||
- anthropic
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
# config/llm.yaml.example
|
|
||||||
# Snipe — LLM backend configuration
|
|
||||||
#
|
|
||||||
# Copy to config/llm.yaml and edit for your setup.
|
|
||||||
# The query builder ("Search with AI") uses the text fallback_order.
|
|
||||||
#
|
|
||||||
# Backends are tried in fallback_order until one succeeds.
|
|
||||||
# Set enabled: false to skip a backend without removing it.
|
|
||||||
#
|
|
||||||
# CF Orchestrator (cf-orch): when CF_ORCH_URL is set in the environment and a
|
|
||||||
# backend has a cf_orch block, allocations are routed through cf-orch for
|
|
||||||
# VRAM-aware scheduling. Omit cf_orch to hit the backend directly.
|
|
||||||
|
|
||||||
backends:
|
|
||||||
anthropic:
|
|
||||||
type: anthropic
|
|
||||||
api_key_env: ANTHROPIC_API_KEY
|
|
||||||
model: claude-haiku-4-5-20251001
|
|
||||||
enabled: false
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
openai:
|
|
||||||
type: openai_compat
|
|
||||||
base_url: https://api.openai.com/v1
|
|
||||||
api_key_env: OPENAI_API_KEY
|
|
||||||
model: gpt-4o-mini
|
|
||||||
enabled: false
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
ollama:
|
|
||||||
type: openai_compat
|
|
||||||
base_url: http://localhost:11434/v1
|
|
||||||
api_key: ollama
|
|
||||||
model: llama3.1:8b
|
|
||||||
enabled: true
|
|
||||||
supports_images: false
|
|
||||||
# Uncomment to route through cf-orch for VRAM-aware scheduling:
|
|
||||||
# cf_orch:
|
|
||||||
# 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
|
|
||||||
- ollama
|
|
||||||
|
|
@ -16,10 +16,6 @@ server {
|
||||||
# Forward the session header injected by Caddy from the cf_session cookie.
|
# Forward the session header injected by Caddy from the cf_session cookie.
|
||||||
# Caddy adds: header_up X-CF-Session {http.request.cookie.cf_session}
|
# Caddy adds: header_up X-CF-Session {http.request.cookie.cf_session}
|
||||||
proxy_set_header X-CF-Session $http_x_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
|
# 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);})();
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
# Tier System
|
# Tier System
|
||||||
|
|
||||||
Snipe uses CircuitForge's three-tier model.
|
Snipe uses Circuit Forge's standard four-tier model.
|
||||||
|
|
||||||
## Tiers
|
## Tiers
|
||||||
|
|
||||||
| Tier | Price | Key features |
|
| Tier | Price | Key features |
|
||||||
|------|-------|-------------|
|
|------|-------|-------------|
|
||||||
| **Free** | Free | Search, trust scoring, red flags, blocklist, market comps, affiliate links, saved searches |
|
| **Free** | Free | Search, trust scoring, red flags, blocklist, market comps, affiliate links, saved searches |
|
||||||
| **Paid** | $5/mo or $129 lifetime | Photo analysis, background monitoring (up to 5 searches), serial number check |
|
| **Paid** | $4.99/mo or $129 lifetime | Photo analysis, background monitoring (up to 5 searches), serial number check |
|
||||||
| **Premium** | $10/mo or $249 lifetime | All Paid features, background monitoring (up to 25), custom affiliate ID (BYOK EPN) |
|
| **Premium** | $9.99/mo or $249 lifetime | All Paid features, background monitoring (up to 25), custom affiliate ID (BYOK EPN) |
|
||||||
|
| **Ultra** | Contact us | Human-in-the-loop assistance |
|
||||||
|
|
||||||
## Free tier philosophy
|
## Free tier philosophy
|
||||||
|
|
||||||
|
|
|
||||||
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
|
- Trust Score Algorithm: reference/trust-scoring.md
|
||||||
- Tier System: reference/tier-system.md
|
- Tier System: reference/tier-system.md
|
||||||
- Architecture: reference/architecture.md
|
- Architecture: reference/architecture.md
|
||||||
|
|
||||||
extra_javascript:
|
|
||||||
- plausible.js
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ version = "0.3.0"
|
||||||
description = "Auction listing monitor and trust scorer"
|
description = "Auction listing monitor and trust scorer"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"circuitforge-core[community]>=0.8.0",
|
"circuitforge-core>=0.8.0",
|
||||||
"streamlit>=1.32",
|
"streamlit>=1.32",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
"imagehash>=4.3",
|
"imagehash>=4.3",
|
||||||
|
|
@ -23,20 +23,14 @@ dependencies = [
|
||||||
"playwright-stealth>=1.0",
|
"playwright-stealth>=1.0",
|
||||||
"cryptography>=42.0",
|
"cryptography>=42.0",
|
||||||
"PyJWT>=2.8",
|
"PyJWT>=2.8",
|
||||||
"httpx>=0.27",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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 = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-cov>=5.0",
|
"pytest-cov>=5.0",
|
||||||
"ruff>=0.4",
|
"ruff>=0.4",
|
||||||
|
"httpx>=0.27", # FastAPI test client
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|
@ -45,9 +39,6 @@ include = ["app*", "api*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
markers = [
|
|
||||||
"browser: tests that require a headed Chromium browser (Xvfb + playwright install required)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
|
||||||
|
|
@ -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,8 +1,6 @@
|
||||||
"""Streamlit entrypoint."""
|
"""Streamlit entrypoint."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from app.wizard import SnipeSetupWizard
|
from app.wizard import SnipeSetupWizard
|
||||||
|
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
|
|
@ -18,7 +16,6 @@ if not wizard.is_configured():
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
from app.ui.components.easter_eggs import inject_konami_detector
|
from app.ui.components.easter_eggs import inject_konami_detector
|
||||||
|
|
||||||
inject_konami_detector()
|
inject_konami_detector()
|
||||||
|
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
|
|
@ -30,5 +27,4 @@ with st.sidebar:
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.ui.Search import render
|
from app.ui.Search import render
|
||||||
|
|
||||||
render(audio_enabled=audio_enabled)
|
render(audio_enabled=audio_enabled)
|
||||||
|
|
|
||||||
|
|
@ -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,9 +1,7 @@
|
||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import requests
|
import requests
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import pytest
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from api.main import _extract_ebay_item_id
|
|
||||||
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,48 +55,3 @@ def test_normalise_seller_maps_fields():
|
||||||
assert seller.feedback_count == 300
|
assert seller.feedback_count == 300
|
||||||
assert seller.feedback_ratio == pytest.approx(0.991, abs=0.001)
|
assert seller.feedback_ratio == pytest.approx(0.991, abs=0.001)
|
||||||
assert seller.account_age_days > 0
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,16 @@
|
||||||
Uses a minimal HTML fixture mirroring eBay's current s-card markup.
|
Uses a minimal HTML fixture mirroring eBay's current s-card markup.
|
||||||
No HTTP requests are made — all tests operate on the pure parsing functions.
|
No HTTP requests are made — all tests operate on the pure parsing functions.
|
||||||
"""
|
"""
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from datetime import timedelta
|
||||||
|
|
||||||
from app.platforms.ebay.scraper import (
|
from app.platforms.ebay.scraper import (
|
||||||
_extract_seller_from_card,
|
|
||||||
_parse_price,
|
|
||||||
_parse_time_left,
|
|
||||||
scrape_listings,
|
scrape_listings,
|
||||||
scrape_sellers,
|
scrape_sellers,
|
||||||
|
_parse_price,
|
||||||
|
_parse_time_left,
|
||||||
|
_extract_seller_from_card,
|
||||||
)
|
)
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Minimal eBay search results HTML fixture (li.s-card schema)
|
# Minimal eBay search results HTML fixture (li.s-card schema)
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
"""Integration tests for POST /api/search/build."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(tmp_path):
|
|
||||||
"""TestClient with a fresh DB and mocked LLMRouter/category cache."""
|
|
||||||
import os
|
|
||||||
os.environ["SNIPE_DB"] = str(tmp_path / "snipe.db")
|
|
||||||
# Import app AFTER setting SNIPE_DB so the DB path is picked up
|
|
||||||
from api.main import app
|
|
||||||
return TestClient(app, raise_server_exceptions=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _good_llm_response() -> str:
|
|
||||||
return json.dumps({
|
|
||||||
"base_query": "RTX 3080",
|
|
||||||
"must_include_mode": "groups",
|
|
||||||
"must_include": "rtx|geforce, 3080",
|
|
||||||
"must_exclude": "mining",
|
|
||||||
"max_price": 300.0,
|
|
||||||
"min_price": None,
|
|
||||||
"condition": ["used"],
|
|
||||||
"category_id": "27386",
|
|
||||||
"explanation": "Used RTX 3080 under $300.",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_endpoint_success(client):
|
|
||||||
with patch("api.main._get_query_translator") as mock_get_t:
|
|
||||||
mock_t = MagicMock()
|
|
||||||
from app.llm.query_translator import SearchParamsResponse
|
|
||||||
mock_t.translate.return_value = SearchParamsResponse(
|
|
||||||
base_query="RTX 3080",
|
|
||||||
must_include_mode="groups",
|
|
||||||
must_include="rtx|geforce, 3080",
|
|
||||||
must_exclude="mining",
|
|
||||||
max_price=300.0,
|
|
||||||
min_price=None,
|
|
||||||
condition=["used"],
|
|
||||||
category_id="27386",
|
|
||||||
explanation="Used RTX 3080 under $300.",
|
|
||||||
)
|
|
||||||
mock_get_t.return_value = mock_t
|
|
||||||
resp = client.post(
|
|
||||||
"/api/search/build",
|
|
||||||
json={"natural_language": "used RTX 3080 under $300 no mining"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["base_query"] == "RTX 3080"
|
|
||||||
assert data["explanation"] == "Used RTX 3080 under $300."
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_endpoint_llm_unavailable(client):
|
|
||||||
with patch("api.main._get_query_translator") as mock_get_t:
|
|
||||||
mock_get_t.return_value = None # no translator configured
|
|
||||||
resp = client.post(
|
|
||||||
"/api/search/build",
|
|
||||||
json={"natural_language": "GPU"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 503
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_endpoint_bad_json(client):
|
|
||||||
with patch("api.main._get_query_translator") as mock_get_t:
|
|
||||||
from app.llm.query_translator import QueryTranslatorError
|
|
||||||
mock_t = MagicMock()
|
|
||||||
mock_t.translate.side_effect = QueryTranslatorError("unparseable", raw="garbage output")
|
|
||||||
mock_get_t.return_value = mock_t
|
|
||||||
resp = client.post(
|
|
||||||
"/api/search/build",
|
|
||||||
json={"natural_language": "GPU"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "raw" in resp.json()["detail"]
|
|
||||||
|
|
@ -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,218 +0,0 @@
|
||||||
"""Unit tests for EbayCategoryCache."""
|
|
||||||
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.platforms.ebay.categories import EbayCategoryCache
|
|
||||||
|
|
||||||
BOOTSTRAP_MIN = 10 # bootstrap must seed at least this many rows
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db(tmp_path):
|
|
||||||
"""In-memory SQLite with migrations applied."""
|
|
||||||
from circuitforge_core.db import get_connection, run_migrations
|
|
||||||
conn = get_connection(tmp_path / "test.db")
|
|
||||||
run_migrations(conn, Path("app/db/migrations"))
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_stale_empty_db(db):
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
assert cache.is_stale() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_stale_fresh(db):
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO ebay_categories (category_id, name, full_path, is_leaf, refreshed_at)"
|
|
||||||
" VALUES (?, ?, ?, 1, ?)",
|
|
||||||
("12345", "Graphics Cards", "Consumer Electronics > GPUs > Graphics Cards", now),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
assert cache.is_stale() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_stale_old(db):
|
|
||||||
old = (datetime.now(timezone.utc) - timedelta(days=8)).isoformat()
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO ebay_categories (category_id, name, full_path, is_leaf, refreshed_at)"
|
|
||||||
" VALUES (?, ?, ?, 1, ?)",
|
|
||||||
("12345", "Graphics Cards", "Consumer Electronics > GPUs > Graphics Cards", old),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
assert cache.is_stale() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_bootstrap_populates_rows(db):
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
cache._seed_bootstrap()
|
|
||||||
cur = db.execute("SELECT COUNT(*) FROM ebay_categories")
|
|
||||||
count = cur.fetchone()[0]
|
|
||||||
assert count >= BOOTSTRAP_MIN
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_relevant_keyword_match(db):
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
cache._seed_bootstrap()
|
|
||||||
results = cache.get_relevant(["GPU", "graphics"], limit=5)
|
|
||||||
ids = [r[0] for r in results]
|
|
||||||
assert "27386" in ids # Graphics Cards
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_relevant_no_match(db):
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
cache._seed_bootstrap()
|
|
||||||
results = cache.get_relevant(["zzznomatch_xyzxyz"], limit=5)
|
|
||||||
assert results == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_relevant_respects_limit(db):
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
cache._seed_bootstrap()
|
|
||||||
results = cache.get_relevant(["electronics"], limit=3)
|
|
||||||
assert len(results) <= 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_for_prompt_returns_rows(db):
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
cache._seed_bootstrap()
|
|
||||||
results = cache.get_all_for_prompt(limit=10)
|
|
||||||
assert len(results) > 0
|
|
||||||
# Each entry is (category_id, full_path)
|
|
||||||
assert all(len(r) == 2 for r in results)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_tree_response() -> dict:
|
|
||||||
"""Minimal eBay Taxonomy API tree response with two leaf nodes."""
|
|
||||||
return {
|
|
||||||
"categoryTreeId": "0",
|
|
||||||
"rootCategoryNode": {
|
|
||||||
"category": {"categoryId": "6000", "categoryName": "Root"},
|
|
||||||
"leafCategoryTreeNode": False,
|
|
||||||
"childCategoryTreeNodes": [
|
|
||||||
{
|
|
||||||
"category": {"categoryId": "6001", "categoryName": "Electronics"},
|
|
||||||
"leafCategoryTreeNode": False,
|
|
||||||
"childCategoryTreeNodes": [
|
|
||||||
{
|
|
||||||
"category": {"categoryId": "6002", "categoryName": "GPUs"},
|
|
||||||
"leafCategoryTreeNode": True,
|
|
||||||
"childCategoryTreeNodes": [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": {"categoryId": "6003", "categoryName": "CPUs"},
|
|
||||||
"leafCategoryTreeNode": True,
|
|
||||||
"childCategoryTreeNodes": [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_inserts_leaf_nodes(db):
|
|
||||||
mock_tm = MagicMock()
|
|
||||||
mock_tm.get_token.return_value = "fake-token"
|
|
||||||
|
|
||||||
tree_resp = MagicMock()
|
|
||||||
tree_resp.raise_for_status = MagicMock()
|
|
||||||
tree_resp.json.return_value = _make_tree_response()
|
|
||||||
|
|
||||||
id_resp = MagicMock()
|
|
||||||
id_resp.raise_for_status = MagicMock()
|
|
||||||
id_resp.json.return_value = {"categoryTreeId": "0"}
|
|
||||||
|
|
||||||
with patch("app.platforms.ebay.categories.requests.get") as mock_get:
|
|
||||||
mock_get.side_effect = [id_resp, tree_resp]
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
count = cache.refresh(mock_tm)
|
|
||||||
|
|
||||||
assert count == 2 # two leaf nodes in our fake tree
|
|
||||||
cur = db.execute("SELECT category_id FROM ebay_categories ORDER BY category_id")
|
|
||||||
ids = {row[0] for row in cur.fetchall()}
|
|
||||||
assert "6002" in ids
|
|
||||||
assert "6003" in ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_no_token_manager_seeds_bootstrap(db):
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
count = cache.refresh(token_manager=None)
|
|
||||||
assert count >= BOOTSTRAP_MIN
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_api_error_logs_warning(db, caplog):
|
|
||||||
import logging
|
|
||||||
mock_tm = MagicMock()
|
|
||||||
mock_tm.get_token.return_value = "fake-token"
|
|
||||||
|
|
||||||
with patch("app.platforms.ebay.categories.requests.get") as mock_get:
|
|
||||||
mock_get.side_effect = Exception("network error")
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
with caplog.at_level(logging.WARNING, logger="app.platforms.ebay.categories"):
|
|
||||||
count = cache.refresh(mock_tm)
|
|
||||||
|
|
||||||
# Falls back to bootstrap on API error
|
|
||||||
assert count >= BOOTSTRAP_MIN
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_publishes_to_community_when_creds_available(db):
|
|
||||||
"""After a successful Taxonomy API refresh, categories are published to community store."""
|
|
||||||
mock_tm = MagicMock()
|
|
||||||
mock_tm.get_token.return_value = "fake-token"
|
|
||||||
|
|
||||||
id_resp = MagicMock()
|
|
||||||
id_resp.raise_for_status = MagicMock()
|
|
||||||
id_resp.json.return_value = {"categoryTreeId": "0"}
|
|
||||||
|
|
||||||
tree_resp = MagicMock()
|
|
||||||
tree_resp.raise_for_status = MagicMock()
|
|
||||||
tree_resp.json.return_value = _make_tree_response()
|
|
||||||
|
|
||||||
mock_community = MagicMock()
|
|
||||||
mock_community.publish_categories.return_value = 2
|
|
||||||
|
|
||||||
with patch("app.platforms.ebay.categories.requests.get") as mock_get:
|
|
||||||
mock_get.side_effect = [id_resp, tree_resp]
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
cache.refresh(mock_tm, community_store=mock_community)
|
|
||||||
|
|
||||||
mock_community.publish_categories.assert_called_once()
|
|
||||||
published = mock_community.publish_categories.call_args[0][0]
|
|
||||||
assert len(published) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_fetches_from_community_when_no_creds(db):
|
|
||||||
"""Without creds, community categories are used when available (>= 10 rows)."""
|
|
||||||
mock_community = MagicMock()
|
|
||||||
mock_community.fetch_categories.return_value = [
|
|
||||||
(str(i), f"Cat {i}", f"Path > Cat {i}") for i in range(15)
|
|
||||||
]
|
|
||||||
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
count = cache.refresh(token_manager=None, community_store=mock_community)
|
|
||||||
|
|
||||||
assert count == 15
|
|
||||||
cur = db.execute("SELECT COUNT(*) FROM ebay_categories")
|
|
||||||
assert cur.fetchone()[0] == 15
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_falls_back_to_bootstrap_when_community_sparse(db):
|
|
||||||
"""Falls back to bootstrap if community returns fewer than 10 rows."""
|
|
||||||
mock_community = MagicMock()
|
|
||||||
mock_community.fetch_categories.return_value = [
|
|
||||||
("1", "Only One", "Path > Only One")
|
|
||||||
]
|
|
||||||
|
|
||||||
cache = EbayCategoryCache(db)
|
|
||||||
count = cache.refresh(token_manager=None, community_store=mock_community)
|
|
||||||
|
|
||||||
assert count >= BOOTSTRAP_MIN
|
|
||||||
|
|
@ -4,10 +4,12 @@ from __future__ import annotations
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from circuitforge_core.api.feedback import make_feedback_router
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from circuitforge_core.api.feedback import make_feedback_router
|
||||||
|
|
||||||
|
|
||||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
# ── Test app factory ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
||||||
|
|
|
||||||
|
|
@ -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,260 +0,0 @@
|
||||||
"""Unit tests for QueryTranslator — LLMRouter and cf-orch backends mocked at boundary."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.llm.query_translator import QueryTranslator, QueryTranslatorError, SearchParamsResponse, _parse_response
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_response ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_parse_response_happy_path():
|
|
||||||
raw = 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.",
|
|
||||||
})
|
|
||||||
result = _parse_response(raw)
|
|
||||||
assert result.base_query == "RTX 3080"
|
|
||||||
assert result.must_include_mode == "groups"
|
|
||||||
assert result.max_price == 300.0
|
|
||||||
assert result.min_price is None
|
|
||||||
assert result.condition == ["used"]
|
|
||||||
assert result.category_id == "27386"
|
|
||||||
assert "RTX 3080" in result.explanation
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_response_missing_optional_fields():
|
|
||||||
raw = json.dumps({
|
|
||||||
"base_query": "vintage camera",
|
|
||||||
"must_include_mode": "all",
|
|
||||||
"must_include": "",
|
|
||||||
"must_exclude": "",
|
|
||||||
"max_price": None,
|
|
||||||
"min_price": None,
|
|
||||||
"condition": [],
|
|
||||||
"category_id": None,
|
|
||||||
"explanation": "Searching for vintage cameras.",
|
|
||||||
})
|
|
||||||
result = _parse_response(raw)
|
|
||||||
assert result.category_id is None
|
|
||||||
assert result.max_price is None
|
|
||||||
assert result.condition == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_response_invalid_json():
|
|
||||||
with pytest.raises(QueryTranslatorError, match="unparseable"):
|
|
||||||
_parse_response("this is not json {{{ garbage")
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_response_missing_required_field():
|
|
||||||
# base_query is required — missing it should raise
|
|
||||||
raw = json.dumps({
|
|
||||||
"must_include_mode": "all",
|
|
||||||
"must_include": "",
|
|
||||||
"must_exclude": "",
|
|
||||||
"max_price": None,
|
|
||||||
"min_price": None,
|
|
||||||
"condition": [],
|
|
||||||
"category_id": None,
|
|
||||||
"explanation": "oops",
|
|
||||||
})
|
|
||||||
with pytest.raises(QueryTranslatorError):
|
|
||||||
_parse_response(raw)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
from app.platforms.ebay.categories import EbayCategoryCache
|
|
||||||
from circuitforge_core.db import get_connection, run_migrations
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db_with_categories(tmp_path):
|
|
||||||
conn = get_connection(tmp_path / "test.db")
|
|
||||||
run_migrations(conn, Path("app/db/migrations"))
|
|
||||||
cache = EbayCategoryCache(conn)
|
|
||||||
cache._seed_bootstrap()
|
|
||||||
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:
|
|
||||||
from app.platforms.ebay.categories import EbayCategoryCache
|
|
||||||
cache = EbayCategoryCache(db_conn)
|
|
||||||
mock_router = MagicMock()
|
|
||||||
mock_router.complete.return_value = llm_response
|
|
||||||
return QueryTranslator(category_cache=cache, llm_router=mock_router)
|
|
||||||
|
|
||||||
|
|
||||||
def test_translate_returns_search_params(db_with_categories):
|
|
||||||
t = _make_local_translator(db_with_categories, _VALID_LLM_RESPONSE)
|
|
||||||
result = t.translate("used RTX 3080 under $300 no mining")
|
|
||||||
assert result.base_query == "RTX 3080"
|
|
||||||
assert result.max_price == 300.0
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
t.translate("GPU")
|
|
||||||
call_args = t._llm_router.complete.call_args
|
|
||||||
system_prompt = call_args.kwargs.get("system") or call_args.args[1]
|
|
||||||
# Bootstrap seeds "27386" for Graphics Cards — should appear in the prompt
|
|
||||||
assert "27386" in system_prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_translate_empty_category_cache_still_works(tmp_path):
|
|
||||||
"""No crash when category cache is empty — prompt uses fallback text."""
|
|
||||||
from circuitforge_core.db import get_connection, run_migrations
|
|
||||||
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({
|
|
||||||
"base_query": "vinyl",
|
|
||||||
"must_include_mode": "all",
|
|
||||||
"must_include": "",
|
|
||||||
"must_exclude": "",
|
|
||||||
"max_price": None,
|
|
||||||
"min_price": None,
|
|
||||||
"condition": [],
|
|
||||||
"category_id": None,
|
|
||||||
"explanation": "Searching for vinyl records.",
|
|
||||||
}))
|
|
||||||
result = t.translate("vinyl records")
|
|
||||||
assert result.base_query == "vinyl"
|
|
||||||
call_args = t._llm_router.complete.call_args
|
|
||||||
system_prompt = call_args.kwargs.get("system") or call_args.args[1]
|
|
||||||
assert "If none match" in system_prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_translate_llm_error_raises_query_translator_error(db_with_categories):
|
|
||||||
from app.platforms.ebay.categories import EbayCategoryCache
|
|
||||||
cache = EbayCategoryCache(db_with_categories)
|
|
||||||
mock_router = MagicMock()
|
|
||||||
mock_router.complete.side_effect = RuntimeError("all backends exhausted")
|
|
||||||
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 json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch, call
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -47,19 +47,6 @@ def tmp_db(tmp_path: Path) -> Path:
|
||||||
return db
|
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():
|
def test_llm_task_types_defined():
|
||||||
assert "trust_photo_analysis" in LLM_TASK_TYPES
|
assert "trust_photo_analysis" in LLM_TASK_TYPES
|
||||||
|
|
||||||
|
|
@ -88,17 +75,29 @@ def test_insert_task_dedup(tmp_db: Path):
|
||||||
assert new2 is False
|
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):
|
vision_result = {
|
||||||
"""Local path: vision result is written to trust_scores.photo_analysis_json."""
|
"is_stock_photo": False,
|
||||||
task_id, _ = insert_task(tmp_db, "trust_photo_analysis", job_id=1, params=_PARAMS)
|
"visible_damage": False,
|
||||||
|
"authenticity_signal": "genuine_product_photo",
|
||||||
|
"confidence": "high",
|
||||||
|
}
|
||||||
|
|
||||||
with patch("app.tasks.runner.requests") as mock_req, \
|
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.content = b"fake_image_bytes"
|
||||||
mock_req.get.return_value.raise_for_status = lambda: None
|
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)
|
conn = sqlite3.connect(tmp_db)
|
||||||
score_row = conn.execute(
|
score_row = conn.execute(
|
||||||
|
|
@ -111,16 +110,20 @@ def test_run_task_photo_analysis_local_success(tmp_db: Path):
|
||||||
assert task_row[0] == "completed"
|
assert task_row[0] == "completed"
|
||||||
parsed = json.loads(score_row[0])
|
parsed = json.loads(score_row[0])
|
||||||
assert parsed["is_stock_photo"] is False
|
assert parsed["is_stock_photo"] is False
|
||||||
assert parsed["confidence"] == "high"
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_task_photo_fetch_failure_marks_failed(tmp_db: Path):
|
def test_run_task_photo_fetch_failure_marks_failed(tmp_db: Path):
|
||||||
"""If photo download fails, task is marked failed without crashing."""
|
"""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:
|
with patch("app.tasks.runner.requests") as mock_req:
|
||||||
mock_req.get.side_effect = ConnectionError("fetch failed")
|
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)
|
conn = sqlite3.connect(tmp_db)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|
@ -153,169 +156,3 @@ def test_run_task_unknown_type_marks_failed(tmp_db: Path):
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
assert row[0] == "failed"
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,3 @@ def test_saved_searches_are_free():
|
||||||
# Ungated: retention feature — friction cost outweighs gate value (see tiers.py)
|
# Ungated: retention feature — friction cost outweighs gate value (see tiers.py)
|
||||||
assert can_use("saved_searches", tier="free") is True
|
assert can_use("saved_searches", tier="free") is True
|
||||||
assert can_use("saved_searches", tier="paid") is True
|
assert can_use("saved_searches", tier="paid") is True
|
||||||
|
|
||||||
|
|
||||||
def test_llm_query_builder_is_paid():
|
|
||||||
assert can_use("llm_query_builder", tier="free") is False
|
|
||||||
assert can_use("llm_query_builder", tier="paid") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_llm_query_builder_local_vision_does_not_unlock():
|
|
||||||
# local vision unlocks photo features only, not LLM query builder
|
|
||||||
assert can_use("llm_query_builder", tier="free", has_local_vision=True) is False
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from app.db.models import Seller
|
from app.db.models import Seller
|
||||||
from app.trust.aggregator import Aggregator
|
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():
|
def test_composite_sum_of_five_signals():
|
||||||
agg = Aggregator()
|
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)
|
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=scraper_seller)
|
||||||
assert "new_account" not in result.red_flags_json
|
assert "new_account" not in result.red_flags_json
|
||||||
assert "account_under_30_days" 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)
|
scores = scorer.score(_seller(), market_median=None, listing_price=950.0)
|
||||||
# None signals "data unavailable" — aggregator will set score_is_partial=True
|
# None signals "data unavailable" — aggregator will set score_is_partial=True
|
||||||
assert scores["price_vs_market"] is None
|
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: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" />
|
<meta name="twitter:image" content="https://menagerie.circuitforge.tech/snipe/og-image.png" />
|
||||||
<link rel="canonical" href="https://menagerie.circuitforge.tech/snipe" />
|
<link rel="canonical" href="https://menagerie.circuitforge.tech/snipe" />
|
||||||
<!-- FOFT guard: prevents dark flash before CSS bundle loads.
|
<!-- Inline background prevents blank flash before CSS bundle loads -->
|
||||||
theme.css overrides both html and body backgrounds via var(--color-surface)
|
<!-- Matches --color-surface dark tactical theme from theme.css -->
|
||||||
once loaded, so this only applies for the brief pre-bundle window. -->
|
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; background: #0d1117; min-height: 100vh; }
|
html, body { margin: 0; background: #0d1117; min-height: 100vh; }
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
|
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
||||||
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
||||||
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
||||||
<!-- Skip to main content — must be first focusable element before the nav -->
|
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
||||||
<AppNav />
|
<AppNav />
|
||||||
<main class="app-main" id="main-content" tabindex="-1">
|
<main class="app-main" id="main-content" tabindex="-1">
|
||||||
|
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -19,37 +19,24 @@ import { onMounted } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import { useMotion } from './composables/useMotion'
|
import { useMotion } from './composables/useMotion'
|
||||||
import { useSnipeMode } from './composables/useSnipeMode'
|
import { useSnipeMode } from './composables/useSnipeMode'
|
||||||
import { useTheme } from './composables/useTheme'
|
|
||||||
import { useKonamiCode } from './composables/useKonamiCode'
|
import { useKonamiCode } from './composables/useKonamiCode'
|
||||||
import { useCandycoreMode } from './composables/useCandycoreMode'
|
|
||||||
import { useSessionStore } from './stores/session'
|
import { useSessionStore } from './stores/session'
|
||||||
import { useBlocklistStore } from './stores/blocklist'
|
import { useBlocklistStore } from './stores/blocklist'
|
||||||
import { usePreferencesStore } from './stores/preferences'
|
|
||||||
import { useReportedStore } from './stores/reported'
|
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
import FeedbackButton from './components/FeedbackButton.vue'
|
import FeedbackButton from './components/FeedbackButton.vue'
|
||||||
|
|
||||||
const motion = useMotion()
|
const motion = useMotion()
|
||||||
const { activate, restore } = useSnipeMode()
|
const { activate, restore } = useSnipeMode()
|
||||||
const { restore: restoreTheme } = useTheme()
|
|
||||||
const { restore: restoreCandy, useWordTrigger } = useCandycoreMode()
|
|
||||||
useWordTrigger()
|
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const blocklistStore = useBlocklistStore()
|
const blocklistStore = useBlocklistStore()
|
||||||
const preferencesStore = usePreferencesStore()
|
|
||||||
const reportedStore = useReportedStore()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
useKonamiCode(activate)
|
useKonamiCode(activate)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
restore() // re-apply snipe mode from localStorage on hard reload
|
restore() // re-apply snipe mode from localStorage on hard reload
|
||||||
restoreTheme() // re-apply explicit theme override on hard reload
|
session.bootstrap() // fetch tier + feature flags from API
|
||||||
restoreCandy() // re-apply candycore mode from localStorage on hard reload
|
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -61,12 +48,6 @@ onMounted(async () => {
|
||||||
padding: 0;
|
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 {
|
html {
|
||||||
font-family: var(--font-body, sans-serif);
|
font-family: var(--font-body, sans-serif);
|
||||||
color: var(--color-text, #e6edf3);
|
color: var(--color-text, #e6edf3);
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import type { Listing, TrustScore, Seller } from '../stores/search'
|
|
||||||
import { useSearchStore } from '../stores/search'
|
|
||||||
|
|
||||||
// ── Mock vue-router — ListingView reads route.params.id ──────────────────────
|
|
||||||
|
|
||||||
const mockRouteId = { value: 'test-listing-id' }
|
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
|
||||||
useRoute: () => ({ params: { id: mockRouteId.value } }),
|
|
||||||
RouterLink: { template: '<a><slot /></a>' },
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeListing(id: string, overrides: Partial<Listing> = {}): Listing {
|
|
||||||
return {
|
|
||||||
id: null, platform: 'ebay', platform_listing_id: id,
|
|
||||||
title: 'NVIDIA RTX 4090 24GB — Used Excellent', price: 849.99,
|
|
||||||
currency: 'USD', condition: 'used_excellent', seller_platform_id: 'seller1',
|
|
||||||
url: 'https://ebay.com/itm/test', photo_urls: ['https://example.com/img.jpg'],
|
|
||||||
listing_age_days: 3, buying_format: 'fixed_price', ends_at: null,
|
|
||||||
fetched_at: null, trust_score_id: null, ...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeTrust(score: number, flags: string[] = [], partial = false): TrustScore {
|
|
||||||
return {
|
|
||||||
id: null, listing_id: 1, composite_score: score,
|
|
||||||
account_age_score: 18, feedback_count_score: 20, feedback_ratio_score: 20,
|
|
||||||
price_vs_market_score: 15, category_history_score: 14,
|
|
||||||
photo_hash_duplicate: false, photo_analysis_json: null,
|
|
||||||
red_flags_json: JSON.stringify(flags), score_is_partial: partial, scored_at: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSeller(overrides: Partial<Seller> = {}): Seller {
|
|
||||||
return {
|
|
||||||
id: null, platform: 'ebay', platform_seller_id: 'seller1',
|
|
||||||
username: 'techdeals_rog', account_age_days: 720, feedback_count: 4711,
|
|
||||||
feedback_ratio: 0.997, category_history_json: '{}', fetched_at: null,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountView(storeSetup?: (store: ReturnType<typeof useSearchStore>) => void) {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
const store = useSearchStore()
|
|
||||||
if (storeSetup) storeSetup(store)
|
|
||||||
|
|
||||||
const { default: ListingView } = await import('../views/ListingView.vue')
|
|
||||||
return mount(ListingView, {
|
|
||||||
global: { plugins: [] },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('ListingView — not found', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRouteId.value = 'missing-id'
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows not-found state when listing is absent from store', async () => {
|
|
||||||
const wrapper = await mountView()
|
|
||||||
expect(wrapper.text()).toContain('Listing not found')
|
|
||||||
expect(wrapper.text()).toContain('Return to search')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render the trust section when listing is absent', async () => {
|
|
||||||
const wrapper = await mountView()
|
|
||||||
expect(wrapper.find('.lv-trust').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ListingView — listing present', () => {
|
|
||||||
const ID = 'test-listing-id'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRouteId.value = ID
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the listing title', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(85))
|
|
||||||
store.sellers.set('seller1', makeSeller())
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('NVIDIA RTX 4090 24GB')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the formatted price', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(85))
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('$849.99')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the composite trust score in the ring', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(72))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-ring__score').text()).toBe('72')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders all five signal rows in the table', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(80))
|
|
||||||
store.sellers.set('seller1', makeSeller())
|
|
||||||
})
|
|
||||||
const rows = wrapper.findAll('.lv-signals__row')
|
|
||||||
expect(rows).toHaveLength(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows score values in signal table', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(80))
|
|
||||||
store.sellers.set('seller1', makeSeller())
|
|
||||||
})
|
|
||||||
// feedback_count_score = 20
|
|
||||||
expect(wrapper.text()).toContain('20 / 20')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows seller username', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(80))
|
|
||||||
store.sellers.set('seller1', makeSeller({ username: 'gpu_warehouse' }))
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('gpu_warehouse')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ListingView — red flags', () => {
|
|
||||||
const ID = 'test-listing-id'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRouteId.value = ID
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders hard flag badge for new_account', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(40, ['new_account']))
|
|
||||||
})
|
|
||||||
const flags = wrapper.findAll('.lv-flag--hard')
|
|
||||||
expect(flags.length).toBeGreaterThan(0)
|
|
||||||
expect(wrapper.text()).toContain('New account')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders soft flag badge for scratch_dent_mentioned', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(55, ['scratch_dent_mentioned']))
|
|
||||||
})
|
|
||||||
const flags = wrapper.findAll('.lv-flag--soft')
|
|
||||||
expect(flags.length).toBeGreaterThan(0)
|
|
||||||
expect(wrapper.text()).toContain('Damage mentioned')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows no flag badges when red_flags_json is empty', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(90, []))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-flag').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies triple-red class when account + price + photo flags all present', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(12, [
|
|
||||||
'new_account', 'suspicious_price', 'duplicate_photo',
|
|
||||||
]))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-layout--triple-red').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not apply triple-red class when only two flag categories present', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(30, ['new_account', 'suspicious_price']))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-layout--triple-red').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ListingView — partial/pending signals', () => {
|
|
||||||
const ID = 'test-listing-id'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRouteId.value = ID
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows pending for account age when seller.account_age_days is null', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(60, [], true))
|
|
||||||
store.sellers.set('seller1', makeSeller({ account_age_days: null }))
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('pending')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows partial warning text when score_is_partial is true', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(60, [], true))
|
|
||||||
store.sellers.set('seller1', makeSeller({ account_age_days: null }))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-verdict__partial').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ListingView — ring colour class', () => {
|
|
||||||
const ID = 'test-listing-id'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRouteId.value = ID
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies lv-ring--high for score >= 80', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(82))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-ring--high').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies lv-ring--mid for score 50–79', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(63))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-ring--mid').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies lv-ring--low for score < 50', async () => {
|
|
||||||
const wrapper = await mountView(store => {
|
|
||||||
store.results.push(makeListing(ID))
|
|
||||||
store.trustScores.set(ID, makeTrust(22))
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.lv-ring--low').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
|
||||||
import { useSearchStore } from '../stores/search'
|
|
||||||
import type { Listing, TrustScore, Seller } from '../stores/search'
|
|
||||||
|
|
||||||
function makeListing(id: string, overrides: Partial<Listing> = {}): Listing {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
platform: 'ebay',
|
|
||||||
platform_listing_id: id,
|
|
||||||
title: `Listing ${id}`,
|
|
||||||
price: 100,
|
|
||||||
currency: 'USD',
|
|
||||||
condition: 'used',
|
|
||||||
seller_platform_id: 'seller1',
|
|
||||||
url: `https://ebay.com/itm/${id}`,
|
|
||||||
photo_urls: [],
|
|
||||||
listing_age_days: 1,
|
|
||||||
buying_format: 'fixed_price',
|
|
||||||
ends_at: null,
|
|
||||||
fetched_at: null,
|
|
||||||
trust_score_id: null,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeTrust(score: number, flags: string[] = []): TrustScore {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
listing_id: 1,
|
|
||||||
composite_score: score,
|
|
||||||
account_age_score: 20,
|
|
||||||
feedback_count_score: 20,
|
|
||||||
feedback_ratio_score: 20,
|
|
||||||
price_vs_market_score: 20,
|
|
||||||
category_history_score: 20,
|
|
||||||
photo_hash_duplicate: false,
|
|
||||||
photo_analysis_json: null,
|
|
||||||
red_flags_json: JSON.stringify(flags),
|
|
||||||
score_is_partial: false,
|
|
||||||
scored_at: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useSearchStore.getListing', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns undefined when results are empty', () => {
|
|
||||||
const store = useSearchStore()
|
|
||||||
expect(store.getListing('abc')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the listing when present in results', () => {
|
|
||||||
const store = useSearchStore()
|
|
||||||
const listing = makeListing('v1|123|0')
|
|
||||||
store.results.push(listing)
|
|
||||||
expect(store.getListing('v1|123|0')).toEqual(listing)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns undefined for an id not in results', () => {
|
|
||||||
const store = useSearchStore()
|
|
||||||
store.results.push(makeListing('v1|123|0'))
|
|
||||||
expect(store.getListing('v1|999|0')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the correct listing when multiple are present', () => {
|
|
||||||
const store = useSearchStore()
|
|
||||||
store.results.push(makeListing('v1|001|0', { title: 'First' }))
|
|
||||||
store.results.push(makeListing('v1|002|0', { title: 'Second' }))
|
|
||||||
store.results.push(makeListing('v1|003|0', { title: 'Third' }))
|
|
||||||
expect(store.getListing('v1|002|0')?.title).toBe('Second')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles URL-encoded pipe characters in listing IDs', () => {
|
|
||||||
const store = useSearchStore()
|
|
||||||
// The route param arrives decoded from vue-router; store uses decoded string
|
|
||||||
const listing = makeListing('v1|157831011297|0')
|
|
||||||
store.results.push(listing)
|
|
||||||
expect(store.getListing('v1|157831011297|0')).toEqual(listing)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useSearchStore trust and seller maps', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('trustScores map returns trust by platform_listing_id', () => {
|
|
||||||
const store = useSearchStore()
|
|
||||||
const trust = makeTrust(85, ['low_feedback_count'])
|
|
||||||
store.trustScores.set('v1|123|0', trust)
|
|
||||||
expect(store.trustScores.get('v1|123|0')?.composite_score).toBe(85)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sellers map returns seller by seller_platform_id', () => {
|
|
||||||
const store = useSearchStore()
|
|
||||||
const seller: Seller = {
|
|
||||||
id: null, platform: 'ebay', platform_seller_id: 'sellerA',
|
|
||||||
username: 'powertech99', account_age_days: 720,
|
|
||||||
feedback_count: 1200, feedback_ratio: 0.998,
|
|
||||||
category_history_json: '{}', fetched_at: null,
|
|
||||||
}
|
|
||||||
store.sellers.set('sellerA', seller)
|
|
||||||
expect(store.sellers.get('sellerA')?.username).toBe('powertech99')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
// Re-import after each test to get a fresh module-level ref
|
|
||||||
// (vi.resetModules() ensures module-level state is cleared between describe blocks)
|
|
||||||
|
|
||||||
describe('useTheme', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
delete document.documentElement.dataset.theme
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults to system when localStorage is empty', async () => {
|
|
||||||
const { useTheme } = await import('../composables/useTheme')
|
|
||||||
const { mode } = useTheme()
|
|
||||||
expect(mode.value).toBe('system')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setMode(dark) sets data-theme=dark on html element', async () => {
|
|
||||||
const { useTheme } = await import('../composables/useTheme')
|
|
||||||
const { setMode } = useTheme()
|
|
||||||
setMode('dark')
|
|
||||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setMode(light) sets data-theme=light on html element', async () => {
|
|
||||||
const { useTheme } = await import('../composables/useTheme')
|
|
||||||
const { setMode } = useTheme()
|
|
||||||
setMode('light')
|
|
||||||
expect(document.documentElement.dataset.theme).toBe('light')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setMode(system) removes data-theme attribute', async () => {
|
|
||||||
const { useTheme } = await import('../composables/useTheme')
|
|
||||||
const { setMode } = useTheme()
|
|
||||||
setMode('dark')
|
|
||||||
setMode('system')
|
|
||||||
expect(document.documentElement.dataset.theme).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setMode persists to localStorage', async () => {
|
|
||||||
const { useTheme } = await import('../composables/useTheme')
|
|
||||||
const { setMode } = useTheme()
|
|
||||||
setMode('dark')
|
|
||||||
expect(localStorage.getItem('snipe:theme')).toBe('dark')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restore() re-applies dark from localStorage', async () => {
|
|
||||||
localStorage.setItem('snipe:theme', 'dark')
|
|
||||||
// Dynamically import a fresh module to simulate hard reload
|
|
||||||
const { useTheme } = await import('../composables/useTheme')
|
|
||||||
const { restore } = useTheme()
|
|
||||||
restore()
|
|
||||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restore() with system mode leaves data-theme absent', async () => {
|
|
||||||
localStorage.setItem('snipe:theme', 'system')
|
|
||||||
const { useTheme } = await import('../composables/useTheme')
|
|
||||||
const { restore } = useTheme()
|
|
||||||
restore()
|
|
||||||
expect(document.documentElement.dataset.theme).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -2,12 +2,6 @@
|
||||||
Dark tactical theme: near-black surfaces, amber accent, trust-signal colours.
|
Dark tactical theme: near-black surfaces, amber accent, trust-signal colours.
|
||||||
ALL color/font/spacing tokens live here — nowhere else.
|
ALL color/font/spacing tokens live here — nowhere else.
|
||||||
Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage).
|
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) ───────────────
|
/* ── Snipe — dark tactical (default) ───────────────
|
||||||
|
|
@ -86,34 +80,8 @@
|
||||||
Warm cream surfaces with the same amber accent.
|
Warm cream surfaces with the same amber accent.
|
||||||
Snipe Mode data attribute overrides this via higher specificity.
|
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"]) {
|
|
||||||
--color-surface: #0d1117;
|
|
||||||
--color-surface-2: #161b22;
|
|
||||||
--color-surface-raised: #1c2129;
|
|
||||||
--color-border: #30363d;
|
|
||||||
--color-border-light: #21262d;
|
|
||||||
--color-text: #e6edf3;
|
|
||||||
--color-text-muted: #8b949e;
|
|
||||||
--color-text-inverse: #0d1117;
|
|
||||||
--app-primary: #f59e0b;
|
|
||||||
--app-primary-hover: #d97706;
|
|
||||||
--app-primary-light: rgba(245, 158, 11, 0.12);
|
|
||||||
--trust-high: #3fb950;
|
|
||||||
--trust-mid: #d29922;
|
|
||||||
--trust-low: #f85149;
|
|
||||||
--color-success: #3fb950;
|
|
||||||
--color-error: #f85149;
|
|
||||||
--color-warning: #d29922;
|
|
||||||
--color-info: #58a6ff;
|
|
||||||
--color-accent: #a478ff;
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(0,0,0,0.6), 0 4px 8px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
:root:not([data-snipe-mode="active"]) {
|
||||||
/* Surfaces — warm cream, like a tactical field notebook */
|
/* Surfaces — warm cream, like a tactical field notebook */
|
||||||
--color-surface: #f8f5ee;
|
--color-surface: #f8f5ee;
|
||||||
--color-surface-2: #f0ece3;
|
--color-surface-2: #f0ece3;
|
||||||
|
|
@ -152,82 +120,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Explicit light override — beats OS preference when user forces light in Settings */
|
|
||||||
[data-theme="light"]:not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
|
||||||
--color-surface: #f8f5ee;
|
|
||||||
--color-surface-2: #f0ece3;
|
|
||||||
--color-surface-raised: #e8e3d8;
|
|
||||||
--color-border: #c8bfae;
|
|
||||||
--color-border-light: #dbd3c4;
|
|
||||||
--color-text: #1c1a16;
|
|
||||||
--color-text-muted: #6b6357;
|
|
||||||
--color-text-inverse: #f8f5ee;
|
|
||||||
--app-primary: #d97706;
|
|
||||||
--app-primary-hover: #b45309;
|
|
||||||
--app-primary-light: rgba(217, 119, 6, 0.12);
|
|
||||||
--trust-high: #16a34a;
|
|
||||||
--trust-mid: #b45309;
|
|
||||||
--trust-low: #dc2626;
|
|
||||||
--color-success: #16a34a;
|
|
||||||
--color-error: #dc2626;
|
|
||||||
--color-warning: #b45309;
|
|
||||||
--color-info: #2563eb;
|
|
||||||
--color-accent: #7c3aed;
|
|
||||||
--shadow-sm: 0 1px 3px rgba(60,45,20,0.12), 0 1px 2px rgba(60,45,20,0.08);
|
|
||||||
--shadow-md: 0 4px 12px rgba(60,45,20,0.15), 0 2px 4px rgba(60,45,20,0.1);
|
|
||||||
--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 ─────────────────── */
|
/* ── Snipe Mode easter egg theme ─────────────────── */
|
||||||
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
|
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
|
||||||
/* Applied: document.documentElement.dataset.snipeMode = 'active' */
|
/* Applied: document.documentElement.dataset.snipeMode = 'active' */
|
||||||
|
|
@ -268,7 +160,7 @@ html {
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-family: var(--font-display);
|
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>
|
<template>
|
||||||
<!-- Desktop: persistent sidebar (≥1024px) -->
|
<!-- Desktop: persistent sidebar (≥1024px) -->
|
||||||
<!-- Mobile: bottom tab bar (<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 -->
|
<!-- Brand -->
|
||||||
<div class="sidebar__brand">
|
<div class="sidebar__brand">
|
||||||
<RouterLink to="/" class="sidebar__logo">
|
<RouterLink to="/" class="sidebar__logo">
|
||||||
|
|
@ -32,20 +32,17 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings + alert bell at bottom -->
|
<!-- Settings at bottom -->
|
||||||
<div class="sidebar__footer">
|
<div class="sidebar__footer">
|
||||||
<div class="sidebar__footer-row">
|
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
||||||
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
<span class="sidebar__label">Settings</span>
|
||||||
<span class="sidebar__label">Settings</span>
|
</RouterLink>
|
||||||
</RouterLink>
|
|
||||||
<AlertBell v-if="session.isLoggedIn || session.tier === 'local'" class="sidebar__bell" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile bottom tab bar -->
|
<!-- 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">
|
<ul class="tabbar__links" role="list">
|
||||||
<li v-for="link in mobileLinks" :key="link.to">
|
<li v-for="link in mobileLinks" :key="link.to">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|
@ -72,11 +69,8 @@ import {
|
||||||
ShieldExclamationIcon,
|
ShieldExclamationIcon,
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
import { useSnipeMode } from '../composables/useSnipeMode'
|
import { useSnipeMode } from '../composables/useSnipeMode'
|
||||||
import { useSessionStore } from '../stores/session'
|
|
||||||
import AlertBell from './AlertBell.vue'
|
|
||||||
|
|
||||||
const { active: isSnipeMode, deactivate } = useSnipeMode()
|
const { active: isSnipeMode, deactivate } = useSnipeMode()
|
||||||
const session = useSessionStore()
|
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
const navLinks = computed(() => [
|
||||||
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
||||||
|
|
@ -87,7 +81,7 @@ const navLinks = computed(() => [
|
||||||
const mobileLinks = [
|
const mobileLinks = [
|
||||||
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
||||||
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
|
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
|
||||||
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Blocklist' },
|
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
|
||||||
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
|
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -208,20 +202,6 @@ const mobileLinks = [
|
||||||
border-top: 1px solid var(--color-border-light);
|
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) ───────────────────────── */
|
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||||
.app-tabbar {
|
.app-tabbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
<!-- web/src/components/LLMQueryPanel.vue -->
|
|
||||||
<!-- BSL 1.1 License -->
|
|
||||||
<template>
|
|
||||||
<div class="llm-panel-wrapper">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="llm-panel-toggle"
|
|
||||||
:class="{ 'llm-panel-toggle--open': isOpen }"
|
|
||||||
:aria-expanded="String(isOpen)"
|
|
||||||
aria-controls="llm-panel"
|
|
||||||
@click="toggle"
|
|
||||||
>
|
|
||||||
Search with AI
|
|
||||||
<span class="llm-panel-toggle__chevron" aria-hidden="true">{{ isOpen ? '▲' : '▾' }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<section
|
|
||||||
id="llm-panel"
|
|
||||||
class="llm-panel"
|
|
||||||
:class="{ 'llm-panel--open': isOpen }"
|
|
||||||
:hidden="!isOpen"
|
|
||||||
>
|
|
||||||
<label for="llm-input" class="llm-panel__label">
|
|
||||||
Describe what you're looking for
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="llm-input"
|
|
||||||
ref="textareaRef"
|
|
||||||
v-model="inputText"
|
|
||||||
class="llm-panel__textarea"
|
|
||||||
rows="2"
|
|
||||||
placeholder="e.g. used RTX 3080 under $300, no mining cards or for-parts listings"
|
|
||||||
:disabled="isLoading"
|
|
||||||
@keydown.escape.prevent="handleEscape"
|
|
||||||
@keydown.ctrl.enter.prevent="onSearch"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="llm-panel__actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="llm-panel__search-btn"
|
|
||||||
:disabled="isLoading || !inputText.trim()"
|
|
||||||
@click="onSearch"
|
|
||||||
>
|
|
||||||
{{ isLoading ? 'Searching…' : 'Search' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
class="llm-panel__status-pill"
|
|
||||||
:class="`llm-panel__status-pill--${status}`"
|
|
||||||
>
|
|
||||||
<span v-if="status === 'thinking'">
|
|
||||||
<span class="llm-panel__spinner" aria-hidden="true" />
|
|
||||||
Thinking…
|
|
||||||
</span>
|
|
||||||
<span v-else-if="status === 'done'">Filters ready</span>
|
|
||||||
<span v-else-if="status === 'error'">Error</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="error" class="llm-panel__error" role="alert">
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="status === 'done' && explanation" class="llm-panel__explanation">
|
|
||||||
{{ explanation }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="llm-panel__autorun">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="autoRun"
|
|
||||||
@change="setAutoRun(($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
Run search automatically
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, nextTick, watch } from 'vue'
|
|
||||||
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
|
|
||||||
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
isLoading,
|
|
||||||
status,
|
|
||||||
explanation,
|
|
||||||
error,
|
|
||||||
autoRun,
|
|
||||||
toggle,
|
|
||||||
setAutoRun,
|
|
||||||
buildQuery,
|
|
||||||
} = useLLMQueryBuilder()
|
|
||||||
|
|
||||||
const inputText = ref('')
|
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
|
||||||
|
|
||||||
watch(isOpen, async (open) => {
|
|
||||||
if (open) {
|
|
||||||
await nextTick()
|
|
||||||
textareaRef.value?.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onSearch() {
|
|
||||||
await buildQuery(inputText.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEscape() {
|
|
||||||
toggle()
|
|
||||||
const toggleBtn = document.querySelector<HTMLButtonElement>('[aria-controls="llm-panel"]')
|
|
||||||
toggleBtn?.focus()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.llm-panel-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle — muted at rest, amber on hover/open. Matches sidebar toolbar buttons. */
|
|
||||||
.llm-panel-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition), border-color var(--transition), color var(--transition);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel-toggle:hover {
|
|
||||||
background: var(--app-primary-light);
|
|
||||||
border-color: var(--app-primary);
|
|
||||||
color: var(--app-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel-toggle--open {
|
|
||||||
background: var(--app-primary-light);
|
|
||||||
border-color: var(--app-primary);
|
|
||||||
color: var(--app-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel */
|
|
||||||
.llm-panel {
|
|
||||||
display: none;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4);
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel--open {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
resize: vertical;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__textarea:focus {
|
|
||||||
outline: 2px solid var(--app-primary);
|
|
||||||
outline-offset: 1px;
|
|
||||||
border-color: var(--app-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search button — same amber style as the main Search button */
|
|
||||||
.llm-panel__search-btn {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
background: var(--app-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__search-btn:hover:not(:disabled) {
|
|
||||||
background: var(--app-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__search-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__status-pill {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__status-pill--idle {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__status-pill--done {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__status-pill--error {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.llm-panel__spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.75em;
|
|
||||||
height: 0.75em;
|
|
||||||
border: 2px solid var(--app-primary);
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: llm-spin 0.7s linear infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 0.25em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes llm-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__error {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-error);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__explanation {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-panel__autorun {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -81,9 +81,6 @@
|
||||||
{{ flagLabel(flag) }}
|
{{ flagLabel(flag) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p v-if="pendingSignalNames.length" class="card__score-pending">
|
||||||
↻ Updating: {{ pendingSignalNames.join(', ') }}
|
↻ Updating: {{ pendingSignalNames.join(', ') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -177,30 +174,20 @@
|
||||||
<span v-if="marketPrice" class="card__market-price" title="Median market price">
|
<span v-if="marketPrice" class="card__market-price" title="Median market price">
|
||||||
market ~{{ formattedMarket }}
|
market ~{{ formattedMarket }}
|
||||||
</span>
|
</span>
|
||||||
<RouterLink
|
|
||||||
:to="`/listing/${listing.platform_listing_id}`"
|
|
||||||
class="card__detail-link"
|
|
||||||
:aria-label="`View trust breakdown for: ${listing.title}`"
|
|
||||||
@click.stop
|
|
||||||
>Details</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 type { Listing, TrustScore, Seller } from '../stores/search'
|
||||||
import { useSearchStore } from '../stores/search'
|
import { useSearchStore } from '../stores/search'
|
||||||
import { useBlocklistStore } from '../stores/blocklist'
|
import { useBlocklistStore } from '../stores/blocklist'
|
||||||
import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
|
import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
|
||||||
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
||||||
import { formatPrice, formatPriceUSD } from '../composables/useCurrency'
|
|
||||||
import { usePreferencesStore } from '../stores/preferences'
|
|
||||||
|
|
||||||
const { enabled: trustSignalEnabled } = useTrustSignalPref()
|
const { enabled: trustSignalEnabled } = useTrustSignalPref()
|
||||||
const prefsStore = usePreferencesStore()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
listing: Listing
|
listing: Listing
|
||||||
|
|
@ -209,7 +196,6 @@ const props = defineProps<{
|
||||||
marketPrice: number | null
|
marketPrice: number | null
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
selectMode?: boolean
|
selectMode?: boolean
|
||||||
sellerReported?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ toggle: [] }>()
|
const emit = defineEmits<{ toggle: [] }>()
|
||||||
|
|
@ -382,26 +368,15 @@ const isSteal = computed(() => {
|
||||||
return props.listing.price < props.marketPrice * 0.8
|
return props.listing.price < props.marketPrice * 0.8
|
||||||
})
|
})
|
||||||
|
|
||||||
// Async price display — show USD synchronously while rates load, then update
|
const formattedPrice = computed(() => {
|
||||||
const formattedPrice = ref(formatPriceUSD(props.listing.price))
|
const sym = props.listing.currency === 'USD' ? '$' : props.listing.currency + ' '
|
||||||
const formattedMarket = ref(props.marketPrice ? formatPriceUSD(props.marketPrice) : '')
|
return `${sym}${props.listing.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||||
|
})
|
||||||
|
|
||||||
async function _updatePrices() {
|
const formattedMarket = computed(() => {
|
||||||
const currency = prefsStore.displayCurrency
|
if (!props.marketPrice) return ''
|
||||||
formattedPrice.value = await formatPrice(props.listing.price, currency)
|
return `$${props.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
|
||||||
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 },
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -547,17 +522,6 @@ watch(
|
||||||
font-weight: 600;
|
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 {
|
.card__partial-warning {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
|
|
@ -769,16 +733,6 @@ watch(
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__detail-link {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--app-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
transition: opacity 150ms ease;
|
|
||||||
}
|
|
||||||
.card__detail-link:hover { opacity: 0.75; }
|
|
||||||
|
|
||||||
/* ── Triple Red easter egg ──────────────────────────────────────────────── */
|
/* ── Triple Red easter egg ──────────────────────────────────────────────── */
|
||||||
/* Fires when: (new_account | account_under_30d) + suspicious_price + hard flag */
|
/* Fires when: (new_account | account_under_30d) + suspicious_price + hard flag */
|
||||||
.listing-card--triple-red {
|
.listing-card--triple-red {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
// web/src/composables/useLLMQueryBuilder.ts
|
|
||||||
// BSL 1.1 License
|
|
||||||
/**
|
|
||||||
* State and API call logic for the LLM query builder panel.
|
|
||||||
*/
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useSearchStore, type SearchParamsResult } from '../stores/search'
|
|
||||||
|
|
||||||
export type BuildStatus = 'idle' | 'thinking' | 'done' | 'error'
|
|
||||||
|
|
||||||
const LS_AUTORUN_KEY = 'snipe:llm-autorun'
|
|
||||||
|
|
||||||
// Module-level refs so state persists across component re-renders
|
|
||||||
const isOpen = ref(false)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const status = ref<BuildStatus>('idle')
|
|
||||||
const explanation = ref<string>('')
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const autoRun = ref<boolean>(localStorage.getItem(LS_AUTORUN_KEY) === 'true')
|
|
||||||
|
|
||||||
export function useLLMQueryBuilder() {
|
|
||||||
const store = useSearchStore()
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
isOpen.value = !isOpen.value
|
|
||||||
if (!isOpen.value) {
|
|
||||||
status.value = 'idle'
|
|
||||||
error.value = null
|
|
||||||
explanation.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAutoRun(value: boolean) {
|
|
||||||
autoRun.value = value
|
|
||||||
localStorage.setItem(LS_AUTORUN_KEY, value ? 'true' : 'false')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildQuery(naturalLanguage: string): Promise<SearchParamsResult | null> {
|
|
||||||
if (!naturalLanguage.trim()) return null
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
status.value = 'thinking'
|
|
||||||
error.value = null
|
|
||||||
explanation.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/search/build', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ natural_language: naturalLanguage.trim() }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const data = await resp.json().catch(() => ({}))
|
|
||||||
const msg = typeof data.detail === 'string'
|
|
||||||
? data.detail
|
|
||||||
: (data.detail?.message ?? `Server error (${resp.status})`)
|
|
||||||
throw new Error(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const params: SearchParamsResult = await resp.json()
|
|
||||||
store.populateFromLLM(params)
|
|
||||||
explanation.value = params.explanation
|
|
||||||
status.value = 'done'
|
|
||||||
|
|
||||||
if (autoRun.value) {
|
|
||||||
await store.search(params.base_query, store.filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Something went wrong.'
|
|
||||||
error.value = msg
|
|
||||||
status.value = 'error'
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOpen,
|
|
||||||
isLoading,
|
|
||||||
status,
|
|
||||||
explanation,
|
|
||||||
error,
|
|
||||||
autoRun,
|
|
||||||
toggle,
|
|
||||||
setAutoRun,
|
|
||||||
buildQuery,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,9 +58,6 @@ export function useSnipeMode(audioEnabled = true) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate() {
|
function activate() {
|
||||||
// Clear candycore if it's on — can't have both
|
|
||||||
delete document.documentElement.dataset.candycore
|
|
||||||
localStorage.removeItem('cf-candycore')
|
|
||||||
active.value = true
|
active.value = true
|
||||||
document.documentElement.dataset[DATA_ATTR] = 'active'
|
document.documentElement.dataset[DATA_ATTR] = 'active'
|
||||||
localStorage.setItem(LS_KEY, 'active')
|
localStorage.setItem(LS_KEY, 'active')
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { ref, watchEffect } from 'vue'
|
|
||||||
|
|
||||||
const LS_KEY = 'snipe:theme'
|
|
||||||
type ThemeMode = 'system' | 'dark' | 'light'
|
|
||||||
|
|
||||||
// Module-level — shared across all callers
|
|
||||||
const mode = ref<ThemeMode>((localStorage.getItem(LS_KEY) as ThemeMode) ?? 'system')
|
|
||||||
|
|
||||||
function _apply(m: ThemeMode) {
|
|
||||||
const el = document.documentElement
|
|
||||||
if (m === 'dark') {
|
|
||||||
el.dataset.theme = 'dark'
|
|
||||||
} else if (m === 'light') {
|
|
||||||
el.dataset.theme = 'light'
|
|
||||||
} else {
|
|
||||||
delete el.dataset.theme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
function setMode(m: ThemeMode) {
|
|
||||||
mode.value = m
|
|
||||||
localStorage.setItem(LS_KEY, m)
|
|
||||||
_apply(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-apply from localStorage on hard reload (call from App.vue onMounted). */
|
|
||||||
function restore() {
|
|
||||||
const saved = (localStorage.getItem(LS_KEY) as ThemeMode) ?? 'system'
|
|
||||||
mode.value = saved
|
|
||||||
_apply(saved)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { mode, setMode, restore }
|
|
||||||
}
|
|
||||||
|
|
@ -10,9 +10,8 @@ export function useTrustFeedback(sellerId: string) {
|
||||||
async function submitFeedback(confirmed: boolean): Promise<void> {
|
async function submitFeedback(confirmed: boolean): Promise<void> {
|
||||||
if (state.value !== 'idle') return
|
if (state.value !== 'idle') return
|
||||||
state.value = 'sending'
|
state.value = 'sending'
|
||||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
|
||||||
try {
|
try {
|
||||||
await fetch(`${apiBase}/api/community/signal`, {
|
await fetch('/api/community/signal', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ seller_id: sellerId, confirmed }),
|
body: JSON.stringify({ seller_id: sellerId, confirmed }),
|
||||||
|
|
|
||||||
|
|
@ -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
|
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', () => {
|
export const usePreferencesStore = defineStore('preferences', () => {
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const prefs = ref<UserPreferences>({})
|
const prefs = ref<UserPreferences>({})
|
||||||
|
|
@ -30,36 +19,15 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
||||||
|
|
||||||
const affiliateOptOut = computed(() => prefs.value.affiliate?.opt_out ?? false)
|
const affiliateOptOut = computed(() => prefs.value.affiliate?.opt_out ?? false)
|
||||||
const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '')
|
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() {
|
async function load() {
|
||||||
if (!session.isLoggedIn) {
|
if (!session.isLoggedIn) return
|
||||||
// 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
|
|
||||||
}
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiBase}/api/preferences`)
|
const res = await fetch('/api/preferences')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: UserPreferences = await res.json()
|
prefs.value = 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
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-cloud deploy or network error — preferences unavailable
|
// Non-cloud deploy or network error — preferences unavailable
|
||||||
|
|
@ -72,7 +40,7 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
||||||
if (!session.isLoggedIn) return
|
if (!session.isLoggedIn) return
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiBase}/api/preferences`, {
|
const res = await fetch('/api/preferences', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path, value }),
|
body: JSON.stringify({ path, value }),
|
||||||
|
|
@ -97,34 +65,14 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
||||||
await setPref('affiliate.byok_ids.ebay', id.trim() || null)
|
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 {
|
return {
|
||||||
prefs,
|
prefs,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
affiliateOptOut,
|
affiliateOptOut,
|
||||||
affiliateByokId,
|
affiliateByokId,
|
||||||
communityBlocklistShare,
|
|
||||||
displayCurrency,
|
|
||||||
load,
|
load,
|
||||||
setAffiliateOptOut,
|
setAffiliateOptOut,
|
||||||
setAffiliateByokId,
|
setAffiliateByokId,
|
||||||
setCommunityBlocklistShare,
|
|
||||||
setDisplayCurrency,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue