Compare commits
No commits in common. "89d3862f62c4dccf3e08dad8af244cd4d0c29048" and "05f845962f0a8467967aec3d4fdc49dde7a8c8d5" have entirely different histories.
89d3862f62
...
05f845962f
31 changed files with 152 additions and 3113 deletions
76
README.md
76
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> *Part of the Circuit Forge LLC "AI for the tasks you hate most" suite.*
|
> *Part of the Circuit Forge LLC "AI for the tasks you hate most" suite.*
|
||||||
|
|
||||||
**Status:** Active — eBay listing intelligence MVP complete; Mercari search + trust scoring live. Auction sniping engine and additional platforms are next.
|
**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.
|
||||||
|
|
||||||
**[Documentation](https://docs.circuitforge.tech/snipe/)** · [circuitforge.tech](https://circuitforge.tech)
|
**[Documentation](https://docs.circuitforge.tech/snipe/)** · [circuitforge.tech](https://circuitforge.tech)
|
||||||
|
|
||||||
|
|
@ -79,18 +79,7 @@ The name is the origin of the word "sniping" — common snipes are notoriously e
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implemented: Listing Intelligence
|
## Implemented: eBay Listing Intelligence
|
||||||
|
|
||||||
### Supported platforms
|
|
||||||
|
|
||||||
| Platform | Search | Trust scoring | Completed-sales comps |
|
|
||||||
|----------|--------|---------------|-----------------------|
|
|
||||||
| **eBay** | ✅ Browse API + Playwright fallback | ✅ All 5 signals | ✅ Marketplace Insights + Browse fallback |
|
|
||||||
| **Mercari** | ✅ Playwright scraper | ✅ Partial (3/5 signals) | ⏳ Phase 3 |
|
|
||||||
|
|
||||||
Switch between platforms via the tab picker in the search UI. All platforms share the same Playwright + Xvfb scraping stack (Cloudflare/Kasada-safe headed Chromium).
|
|
||||||
|
|
||||||
### eBay Listing Intelligence
|
|
||||||
|
|
||||||
### Search & filtering
|
### Search & filtering
|
||||||
- Full-text eBay search via Browse API (with Playwright scraper fallback when no API credentials configured)
|
- Full-text eBay search via Browse API (with Playwright scraper fallback when no API credentials configured)
|
||||||
|
|
@ -164,24 +153,6 @@ Completed sales fetched via eBay Marketplace Insights API (with Browse API fallb
|
||||||
| Playwright scraper (`scraper`) | No credentials / forced | All signals except account age (async BTF enrichment) |
|
| Playwright scraper (`scraper`) | No credentials / forced | All signals except account age (async BTF enrichment) |
|
||||||
| `auto` (default) | — | API if credentials present, scraper otherwise |
|
| `auto` (default) | — | API if credentials present, scraper otherwise |
|
||||||
|
|
||||||
### Mercari Listing Intelligence
|
|
||||||
|
|
||||||
Search Mercari US via headed Chromium + playwright-stealth, bypassing Cloudflare Turnstile. Uses the same `BrowserPool` as the eBay scraper.
|
|
||||||
|
|
||||||
**Trust signal coverage:**
|
|
||||||
|
|
||||||
| Signal | Source | Available |
|
|
||||||
|--------|--------|-----------|
|
|
||||||
| `feedback_count` | `NumSales` on listing page | ✅ |
|
|
||||||
| `feedback_ratio` | `ReviewStarsWrapper[data-stars]` ÷ 5 | ✅ |
|
|
||||||
| `price_vs_market` | Computed from comps (Phase 3) | ⏳ |
|
|
||||||
| `account_age_days` | Seller profile page (not yet fetched) | ❌ |
|
|
||||||
| `category_history` | Not exposed in Mercari HTML | ❌ |
|
|
||||||
|
|
||||||
All Mercari scores are marked **partial** (`score_is_partial=True`) because account age and category history are unavailable. The trust scorer handles partial scores correctly — missing signals don't penalise the seller.
|
|
||||||
|
|
||||||
**Design note:** `seller_platform_id` stores the Mercari `product_id` (e.g. `m86032668393`) rather than the seller username, because seller identity isn't available from search results HTML. `get_seller()` resolves the product ID by fetching the individual listing page.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
@ -213,33 +184,24 @@ docker compose -f compose.cloud.yml -p snipe-cloud build api # after Python cha
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Intelligence features
|
### Near-term (eBay)
|
||||||
|
|
||||||
| Issue | Feature |
|
| 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) |
|
| [#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 |
|
| [#12](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/12) | Background saved-search monitoring with configurable alerts |
|
||||||
| [#21](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/21) | Vision classification pipeline — condition scoring, listing quality, fraud signals |
|
|
||||||
| [#43](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/43) | Wire photo analysis task to cf-orch (VRAM-aware scheduling) |
|
|
||||||
| [#51](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/51) | Reranker: semantic filter before trust scoring |
|
|
||||||
| [#52](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/52) | Trust score fix: exclude buyer-only feedback from `feedback_count` |
|
|
||||||
| [#41](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/41) | Additional theme variants — solarized, high-contrast, colorblind-safe |
|
|
||||||
|
|
||||||
### Platform expansion
|
|
||||||
|
|
||||||
| Issue | Feature |
|
|
||||||
|-------|---------|
|
|
||||||
| ✅ shipped | Mercari US — search + partial trust scoring |
|
|
||||||
| [#53](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/53) | BrowserPool thread-safety — eliminate per-request cold-start (~10s) |
|
|
||||||
| [#10](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/10) | CT Bids, HiBid, AuctionZip, Invaluable, GovPlanet, Bidsquare, Proxibid |
|
|
||||||
| [#46](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/46) | Broadcast trust score verdicts to Fediverse communities via ActivityPub |
|
|
||||||
|
|
||||||
### Cloud / infrastructure
|
### Cloud / infrastructure
|
||||||
|
|
||||||
| Issue | Feature |
|
| 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) |
|
| [#7](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7) | Shared image hash DB — requires explicit opt-in consent (CF privacy-by-architecture) |
|
||||||
| [#45](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/45) | Migrate shared seller/comps DB from SQLite to Postgres |
|
|
||||||
|
|
||||||
### Auction sniping engine
|
### Auction sniping engine
|
||||||
|
|
||||||
|
|
@ -248,29 +210,17 @@ docker compose -f compose.cloud.yml -p snipe-cloud build api # after Python cha
|
||||||
| [#9](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/9) | Bid scheduling + snipe execution (NTP-synchronized, soft-close handling, human approval gate) |
|
| [#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 |
|
| [#13](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/13) | Post-win workflow: payment routing, shipping coordination, provenance documentation |
|
||||||
|
|
||||||
### Already shipped
|
### Multi-platform expansion
|
||||||
|
|
||||||
| Issue | Feature |
|
| Issue | Feature |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| [#1](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/1) | SSE live score push — enriched data appears without re-search |
|
| [#10](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/10) | CT Bids, HiBid, AuctionZip, Invaluable, GovPlanet, Bidsquare, Proxibid |
|
||||||
| [#2](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/2) | eBay OAuth for full trust score access via Trading API |
|
|
||||||
| [#4](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/4) | Community blocklist + batch eBay Trust & Safety reporting |
|
|
||||||
| [#6](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/6) | Shared seller/scammer/comps DB across cloud users |
|
|
||||||
| [#8](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/8) | "Triple Red" easter egg |
|
|
||||||
| [#11](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/11) | Vision-based photo condition assessment — moondream2 / Claude vision |
|
|
||||||
| [#27](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/27) | MCP server for Snipe search and scoring |
|
|
||||||
| [#29](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/29) | LLM query builder — describe what to find, AI builds the search |
|
|
||||||
| [#47](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/47) | Browser pool — pre-warm Chromium to cut scrape cold-start |
|
|
||||||
| [#48](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/48) | Search result caching — skip redundant scrapes for repeated queries |
|
|
||||||
| [#49](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/49) | Async search endpoint — return job ID immediately, scrape in background |
|
|
||||||
| [#50](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/50) | Currency preference — display prices in user's preferred currency |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Primary platforms (full vision)
|
## Primary platforms (full vision)
|
||||||
|
|
||||||
- **eBay** — general + collectibles *(search + trust scoring: implemented)*
|
- **eBay** — general + collectibles *(search + trust scoring: implemented)*
|
||||||
- **Mercari** — US resale marketplace *(search + partial trust scoring: implemented; comps Phase 3)*
|
|
||||||
- **CT Bids** — Connecticut state surplus and municipal auctions
|
- **CT Bids** — Connecticut state surplus and municipal auctions
|
||||||
- **GovPlanet / IronPlanet** — government surplus equipment
|
- **GovPlanet / IronPlanet** — government surplus equipment
|
||||||
- **AuctionZip** — antique auction house aggregator (1,000+ houses)
|
- **AuctionZip** — antique auction house aggregator (1,000+ houses)
|
||||||
|
|
@ -313,9 +263,9 @@ Online auctions are frustrating because:
|
||||||
## Tech notes
|
## Tech notes
|
||||||
|
|
||||||
- Shared `circuitforge-core` scaffold (DB, LLM router, tier system, config)
|
- Shared `circuitforge-core` scaffold (DB, LLM router, tier system, config)
|
||||||
- Platform adapters: eBay (Browse API + scraper) and Mercari (scraper); AuctionZip, Invaluable, HiBid, CT Bids planned (Playwright + API where available)
|
- Platform adapters: currently eBay only; AuctionZip, Invaluable, HiBid, CT Bids planned (Playwright + API where available)
|
||||||
- Bid execution: Playwright automation with precise timing (NTP-synchronized)
|
- Bid execution: Playwright automation with precise timing (NTP-synchronized)
|
||||||
- Soft-close detection: platform-specific rules engine
|
- Soft-close detection: platform-specific rules engine
|
||||||
- Comparable sales: eBay completed listings via Marketplace Insights API + Browse API fallback
|
- 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`)
|
- Vision module: condition assessment from listing photos — moondream2 / Claude vision (paid tier stub in `app/trust/photo.py`)
|
||||||
- **Kasada/Cloudflare bypass**: headed Chromium via Xvfb with playwright-stealth; all scraping uses this path — headless and `requests`-based approaches are blocked by eBay and Mercari. Xvfb started with `-ac` (no X11 auth required in Docker), display range `:200+` to avoid host socket conflicts.
|
- **Kasada bypass**: headed Chromium via Xvfb; all scraping uses this path — headless and `requests`-based approaches are blocked by eBay
|
||||||
|
|
|
||||||
121
api/main.py
121
api/main.py
|
|
@ -24,7 +24,7 @@ from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
||||||
from circuitforge_core.api import make_corrections_router as _make_corrections_router
|
from circuitforge_core.api import make_corrections_router as _make_corrections_router
|
||||||
from circuitforge_core.api import make_feedback_router as _make_feedback_router
|
from circuitforge_core.api import make_feedback_router as _make_feedback_router
|
||||||
from circuitforge_core.config import load_env
|
from circuitforge_core.config import load_env
|
||||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, Response, UploadFile
|
from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -34,7 +34,7 @@ from api.ebay_webhook import router as ebay_webhook_router
|
||||||
from app.db.models import SavedSearch as SavedSearchModel
|
from app.db.models import SavedSearch as SavedSearchModel
|
||||||
from app.db.models import ScammerEntry
|
from app.db.models import ScammerEntry
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.platforms import SUPPORTED_PLATFORMS, SearchFilters
|
from app.platforms import SearchFilters
|
||||||
from app.platforms.ebay.adapter import EbayAdapter
|
from app.platforms.ebay.adapter import EbayAdapter
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
from app.platforms.ebay.query_builder import expand_queries, parse_groups
|
from app.platforms.ebay.query_builder import expand_queries, parse_groups
|
||||||
|
|
@ -664,10 +664,10 @@ def _try_trading_api_enrichment(
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
def _make_adapter(shared_store: Store, force: str = "auto", platform: str = "ebay"):
|
def _make_adapter(shared_store: Store, force: str = "auto"):
|
||||||
"""Return the appropriate adapter for the given platform.
|
"""Return the appropriate adapter.
|
||||||
|
|
||||||
force: "auto" | "api" | "scraper" (ignored for non-eBay platforms)
|
force: "auto" | "api" | "scraper"
|
||||||
auto — API if creds present, else scraper
|
auto — API if creds present, else scraper
|
||||||
api — Browse API (raises if no creds)
|
api — Browse API (raises if no creds)
|
||||||
scraper — Playwright scraper regardless of creds
|
scraper — Playwright scraper regardless of creds
|
||||||
|
|
@ -675,11 +675,6 @@ def _make_adapter(shared_store: Store, force: str = "auto", platform: str = "eba
|
||||||
Adapters receive shared_store because they only read/write sellers and
|
Adapters receive shared_store because they only read/write sellers and
|
||||||
market_comps — never listings. Listings are returned and saved by the caller.
|
market_comps — never listings. Listings are returned and saved by the caller.
|
||||||
"""
|
"""
|
||||||
if platform == "mercari":
|
|
||||||
from app.platforms.mercari import MercariAdapter
|
|
||||||
return MercariAdapter(shared_store)
|
|
||||||
|
|
||||||
# eBay
|
|
||||||
client_id, client_secret, env = _ebay_creds()
|
client_id, client_secret, env = _ebay_creds()
|
||||||
has_creds = bool(client_id and client_secret)
|
has_creds = bool(client_id and client_secret)
|
||||||
|
|
||||||
|
|
@ -696,10 +691,8 @@ def _make_adapter(shared_store: Store, force: str = "auto", platform: str = "eba
|
||||||
return ScrapedEbayAdapter(shared_store)
|
return ScrapedEbayAdapter(shared_store)
|
||||||
|
|
||||||
|
|
||||||
def _adapter_name(force: str = "auto", platform: str = "ebay") -> str:
|
def _adapter_name(force: str = "auto") -> str:
|
||||||
"""Return the name of the adapter that would be used — without creating it."""
|
"""Return the name of the adapter that would be used — without creating it."""
|
||||||
if platform != "ebay":
|
|
||||||
return platform
|
|
||||||
client_id, client_secret, _ = _ebay_creds()
|
client_id, client_secret, _ = _ebay_creds()
|
||||||
if force == "scraper":
|
if force == "scraper":
|
||||||
return "scraper"
|
return "scraper"
|
||||||
|
|
@ -720,15 +713,8 @@ def search(
|
||||||
category_id: str = "", # eBay category ID — forwarded to Browse API / scraper _sacat
|
category_id: str = "", # eBay category ID — forwarded to Browse API / scraper _sacat
|
||||||
adapter: str = "auto", # "auto" | "api" | "scraper" — override adapter selection
|
adapter: str = "auto", # "auto" | "api" | "scraper" — override adapter selection
|
||||||
refresh: bool = False, # when True, bypass cache read (still writes fresh result)
|
refresh: bool = False, # when True, bypass cache read (still writes fresh result)
|
||||||
platform: str = Query("ebay", description="Marketplace platform to search"),
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
if platform not in SUPPORTED_PLATFORMS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Platform {platform!r} is not yet supported. Supported: {sorted(SUPPORTED_PLATFORMS)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# If the user pasted an eBay listing or checkout URL, extract the item ID
|
# If the user pasted an eBay listing or checkout URL, extract the item ID
|
||||||
# and use it as the search query so the exact item surfaces in results.
|
# and use it as the search query so the exact item surfaces in results.
|
||||||
ebay_item_id = _extract_ebay_item_id(q)
|
ebay_item_id = _extract_ebay_item_id(q)
|
||||||
|
|
@ -737,7 +723,7 @@ def search(
|
||||||
q = ebay_item_id
|
q = ebay_item_id
|
||||||
|
|
||||||
if not q.strip():
|
if not q.strip():
|
||||||
return {"listings": [], "trust_scores": {}, "sellers": {}, "market_price": None, "adapter_used": _adapter_name(adapter, platform=platform)}
|
return {"listings": [], "trust_scores": {}, "sellers": {}, "market_price": None, "adapter_used": _adapter_name(adapter)}
|
||||||
|
|
||||||
# Cap pages to the tier's maximum — free cloud users get 1 page, local gets unlimited.
|
# Cap pages to the tier's maximum — free cloud users get 1 page, local gets unlimited.
|
||||||
features = compute_features(session.tier)
|
features = compute_features(session.tier)
|
||||||
|
|
@ -745,8 +731,9 @@ def search(
|
||||||
|
|
||||||
must_exclude_terms = _parse_terms(must_exclude)
|
must_exclude_terms = _parse_terms(must_exclude)
|
||||||
|
|
||||||
# OR-group expansion is eBay-specific; other platforms use the base query directly.
|
# In Groups mode, expand OR groups into multiple targeted eBay queries to
|
||||||
if platform == "ebay" and must_include_mode == "groups" and must_include.strip():
|
# guarantee comprehensive result coverage — eBay relevance won't silently drop variants.
|
||||||
|
if must_include_mode == "groups" and must_include.strip():
|
||||||
or_groups = parse_groups(must_include)
|
or_groups = parse_groups(must_include)
|
||||||
ebay_queries = expand_queries(q, or_groups)
|
ebay_queries = expand_queries(q, or_groups)
|
||||||
else:
|
else:
|
||||||
|
|
@ -773,7 +760,7 @@ def search(
|
||||||
category_id=category_id.strip() or None,
|
category_id=category_id.strip() or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter_used = _adapter_name(adapter, platform=platform)
|
adapter_used = _adapter_name(adapter)
|
||||||
|
|
||||||
shared_db = session.shared_db
|
shared_db = session.shared_db
|
||||||
user_db = session.user_db
|
user_db = session.user_db
|
||||||
|
|
@ -833,11 +820,11 @@ def search(
|
||||||
}
|
}
|
||||||
seller_map = {
|
seller_map = {
|
||||||
listing.seller_platform_id: dataclasses.asdict(
|
listing.seller_platform_id: dataclasses.asdict(
|
||||||
shared_store.get_seller(platform, listing.seller_platform_id)
|
shared_store.get_seller("ebay", listing.seller_platform_id)
|
||||||
)
|
)
|
||||||
for listing in listings
|
for listing in listings
|
||||||
if listing.seller_platform_id
|
if listing.seller_platform_id
|
||||||
and shared_store.get_seller(platform, listing.seller_platform_id)
|
and shared_store.get_seller("ebay", listing.seller_platform_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
_is_unauthed = session.user_id == "anonymous" or session.user_id.startswith("guest:")
|
_is_unauthed = session.user_id == "anonymous" or session.user_id.startswith("guest:")
|
||||||
|
|
@ -891,11 +878,11 @@ def search(
|
||||||
|
|
||||||
# Each thread creates its own Store — sqlite3 check_same_thread=True.
|
# Each thread creates its own Store — sqlite3 check_same_thread=True.
|
||||||
def _run_search(ebay_query: str) -> list:
|
def _run_search(ebay_query: str) -> list:
|
||||||
return _make_adapter(Store(shared_db), adapter, platform=platform).search(ebay_query, base_filters)
|
return _make_adapter(Store(shared_db), adapter).search(ebay_query, base_filters)
|
||||||
|
|
||||||
def _run_comps() -> None:
|
def _run_comps() -> None:
|
||||||
try:
|
try:
|
||||||
_make_adapter(Store(shared_db), adapter, platform=platform).get_completed_sales(comp_query, pages)
|
_make_adapter(Store(shared_db), adapter).get_completed_sales(comp_query, pages)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("comps: unhandled exception for %r", comp_query, exc_info=True)
|
log.warning("comps: unhandled exception for %r", comp_query, exc_info=True)
|
||||||
|
|
||||||
|
|
@ -922,8 +909,8 @@ def search(
|
||||||
raise HTTPException(status_code=502, detail=f"eBay search failed: {e}")
|
raise HTTPException(status_code=502, detail=f"eBay search failed: {e}")
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"search platform=%s auth=%s tier=%s adapter=%s pages=%d queries=%d listings=%d q=%r",
|
"search auth=%s tier=%s adapter=%s pages=%d queries=%d listings=%d q=%r",
|
||||||
platform, _auth_label(session.user_id), session.tier, adapter_used,
|
_auth_label(session.user_id), session.tier, adapter_used,
|
||||||
pages, len(ebay_queries), len(listings), q,
|
pages, len(ebay_queries), len(listings), q,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -944,23 +931,25 @@ def search(
|
||||||
|
|
||||||
user_store.save_listings(listings)
|
user_store.save_listings(listings)
|
||||||
|
|
||||||
# Derive category_history from accumulated listing data — eBay only
|
# Derive category_history from accumulated listing data — free for API adapter
|
||||||
# (category_name comes from Browse API response; other platforms return None).
|
# (category_name comes from Browse API response), no-op for scraper listings (category_name=None).
|
||||||
|
# Reads listings from user_store, writes seller categories to shared_store.
|
||||||
seller_ids = list({l.seller_platform_id for l in listings if l.seller_platform_id})
|
seller_ids = list({l.seller_platform_id for l in listings if l.seller_platform_id})
|
||||||
if platform == "ebay":
|
|
||||||
n_cat = shared_store.refresh_seller_categories("ebay", seller_ids, listing_store=user_store)
|
n_cat = shared_store.refresh_seller_categories("ebay", seller_ids, listing_store=user_store)
|
||||||
if n_cat:
|
if n_cat:
|
||||||
log.info("Category history derived for %d sellers from listing data", n_cat)
|
log.info("Category history derived for %d sellers from listing data", n_cat)
|
||||||
|
|
||||||
# Re-fetch to hydrate staging fields (times_seen, first_seen_at, id, price_at_first_seen)
|
# Re-fetch to hydrate staging fields (times_seen, first_seen_at, id, price_at_first_seen)
|
||||||
# that are only available from the DB after the upsert.
|
# that are only available from the DB after the upsert.
|
||||||
staged = user_store.get_listings_staged(platform, [l.platform_listing_id for l in listings])
|
staged = user_store.get_listings_staged("ebay", [l.platform_listing_id for l in listings])
|
||||||
listings = [staged.get(l.platform_listing_id, l) for l in listings]
|
listings = [staged.get(l.platform_listing_id, l) for l in listings]
|
||||||
|
|
||||||
# Trading API enrichment and BTF scraping are eBay-specific.
|
# Trading API enrichment: if the user has connected their eBay account, use
|
||||||
_main_adapter = _make_adapter(shared_store, adapter, platform=platform)
|
# Trading API GetUser to instantly fill account_age_days for sellers missing it.
|
||||||
trading_api_enriched: set[str] = set()
|
# This is synchronous (~200ms per seller) but only runs for sellers that need
|
||||||
if platform == "ebay":
|
# enrichment — typically a small subset. Sellers resolved here are excluded from
|
||||||
|
# the slower BTF Playwright background pass.
|
||||||
|
_main_adapter = _make_adapter(shared_store, adapter)
|
||||||
sellers_needing_age = [
|
sellers_needing_age = [
|
||||||
l.seller_platform_id for l in listings
|
l.seller_platform_id for l in listings
|
||||||
if l.seller_platform_id
|
if l.seller_platform_id
|
||||||
|
|
@ -974,7 +963,9 @@ def search(
|
||||||
_main_adapter, sellers_needing_age, user_db
|
_main_adapter, sellers_needing_age, user_db
|
||||||
)
|
)
|
||||||
|
|
||||||
# BTF enrichment: scrape /itm/ pages for sellers still missing account_age_days.
|
# BTF enrichment: scrape /itm/ pages for sellers still missing account_age_days
|
||||||
|
# after the Trading API pass. Runs in the background so it doesn't delay the
|
||||||
|
# response. Live score updates are pushed to the pre-registered SSE queue.
|
||||||
_trigger_scraper_enrichment(
|
_trigger_scraper_enrichment(
|
||||||
listings, shared_store, shared_db,
|
listings, shared_store, shared_db,
|
||||||
user_db=user_db, query=comp_query, session_id=session_id,
|
user_db=user_db, query=comp_query, session_id=session_id,
|
||||||
|
|
@ -993,7 +984,7 @@ def search(
|
||||||
_enqueue_vision_tasks(listings, trust_scores_list, session)
|
_enqueue_vision_tasks(listings, trust_scores_list, session)
|
||||||
|
|
||||||
query_hash = hashlib.md5(comp_query.encode()).hexdigest()
|
query_hash = hashlib.md5(comp_query.encode()).hexdigest()
|
||||||
comp = shared_store.get_market_comp(platform, query_hash)
|
comp = shared_store.get_market_comp("ebay", query_hash)
|
||||||
market_price = comp.median_price if comp else None
|
market_price = comp.median_price if comp else None
|
||||||
|
|
||||||
# Store raw listings (as dicts) + market_price in cache.
|
# Store raw listings (as dicts) + market_price in cache.
|
||||||
|
|
@ -1012,11 +1003,11 @@ def search(
|
||||||
}
|
}
|
||||||
seller_map = {
|
seller_map = {
|
||||||
listing.seller_platform_id: dataclasses.asdict(
|
listing.seller_platform_id: dataclasses.asdict(
|
||||||
shared_store.get_seller(platform, listing.seller_platform_id)
|
shared_store.get_seller("ebay", listing.seller_platform_id)
|
||||||
)
|
)
|
||||||
for listing in listings
|
for listing in listings
|
||||||
if listing.seller_platform_id
|
if listing.seller_platform_id
|
||||||
and shared_store.get_seller(platform, listing.seller_platform_id)
|
and shared_store.get_seller("ebay", listing.seller_platform_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build a preference reader for affiliate URL wrapping.
|
# Build a preference reader for affiliate URL wrapping.
|
||||||
|
|
@ -1082,7 +1073,6 @@ def search_async(
|
||||||
category_id: str = "",
|
category_id: str = "",
|
||||||
adapter: str = "auto",
|
adapter: str = "auto",
|
||||||
refresh: bool = False, # when True, bypass cache read (still writes fresh result)
|
refresh: bool = False, # when True, bypass cache read (still writes fresh result)
|
||||||
platform: str = Query("ebay", description="Marketplace platform to search"),
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Async variant of GET /api/search.
|
"""Async variant of GET /api/search.
|
||||||
|
|
@ -1098,12 +1088,6 @@ def search_async(
|
||||||
"seller": {...}, "market_price": ...} (enrichment updates)
|
"seller": {...}, "market_price": ...} (enrichment updates)
|
||||||
None (sentinel — stream finished)
|
None (sentinel — stream finished)
|
||||||
"""
|
"""
|
||||||
if platform not in SUPPORTED_PLATFORMS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Platform {platform!r} is not yet supported. Supported: {sorted(SUPPORTED_PLATFORMS)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate / normalise params — same logic as synchronous endpoint.
|
# Validate / normalise params — same logic as synchronous endpoint.
|
||||||
ebay_item_id = _extract_ebay_item_id(q)
|
ebay_item_id = _extract_ebay_item_id(q)
|
||||||
if ebay_item_id:
|
if ebay_item_id:
|
||||||
|
|
@ -1120,7 +1104,7 @@ def search_async(
|
||||||
"trust_scores": {},
|
"trust_scores": {},
|
||||||
"sellers": {},
|
"sellers": {},
|
||||||
"market_price": None,
|
"market_price": None,
|
||||||
"adapter_used": _adapter_name(adapter, platform=platform),
|
"adapter_used": _adapter_name(adapter),
|
||||||
"affiliate_active": bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip()),
|
"affiliate_active": bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip()),
|
||||||
})
|
})
|
||||||
_update_queues[empty_id].put(None)
|
_update_queues[empty_id].put(None)
|
||||||
|
|
@ -1149,8 +1133,7 @@ def search_async(
|
||||||
q_norm = q # captured from outer scope
|
q_norm = q # captured from outer scope
|
||||||
must_exclude_terms = _parse_terms(must_exclude)
|
must_exclude_terms = _parse_terms(must_exclude)
|
||||||
|
|
||||||
# OR-group expansion is eBay-specific; other platforms use the base query directly.
|
if must_include_mode == "groups" and must_include.strip():
|
||||||
if platform == "ebay" and must_include_mode == "groups" and must_include.strip():
|
|
||||||
or_groups = parse_groups(must_include)
|
or_groups = parse_groups(must_include)
|
||||||
ebay_queries = expand_queries(q_norm, or_groups)
|
ebay_queries = expand_queries(q_norm, or_groups)
|
||||||
else:
|
else:
|
||||||
|
|
@ -1172,7 +1155,7 @@ def search_async(
|
||||||
category_id=category_id.strip() or None,
|
category_id=category_id.strip() or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter_used = _adapter_name(adapter, platform=platform)
|
adapter_used = _adapter_name(adapter)
|
||||||
q_ref = _update_queues.get(session_id)
|
q_ref = _update_queues.get(session_id)
|
||||||
if q_ref is None:
|
if q_ref is None:
|
||||||
return # client disconnected before we even started
|
return # client disconnected before we even started
|
||||||
|
|
@ -1279,11 +1262,11 @@ def search_async(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
def _run_search(ebay_query: str) -> list:
|
def _run_search(ebay_query: str) -> list:
|
||||||
return _make_adapter(Store(_shared_db), adapter, platform=platform).search(ebay_query, base_filters)
|
return _make_adapter(Store(_shared_db), adapter).search(ebay_query, base_filters)
|
||||||
|
|
||||||
def _run_comps() -> None:
|
def _run_comps() -> None:
|
||||||
try:
|
try:
|
||||||
_make_adapter(Store(_shared_db), adapter, platform=platform).get_completed_sales(comp_query, pages)
|
_make_adapter(Store(_shared_db), adapter).get_completed_sales(comp_query, pages)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("async comps: unhandled exception for %r", comp_query, exc_info=True)
|
log.warning("async comps: unhandled exception for %r", comp_query, exc_info=True)
|
||||||
|
|
||||||
|
|
@ -1302,8 +1285,8 @@ def search_async(
|
||||||
comps_future.result()
|
comps_future.result()
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"async_search platform=%s auth=%s tier=%s adapter=%s pages=%d listings=%d q=%r",
|
"async_search auth=%s tier=%s adapter=%s pages=%d listings=%d q=%r",
|
||||||
platform, _auth_label(_user_id), _tier, adapter_used, pages, len(listings), q_norm,
|
_auth_label(_user_id), _tier, adapter_used, pages, len(listings), q_norm,
|
||||||
)
|
)
|
||||||
|
|
||||||
shared_store = Store(_shared_db)
|
shared_store = Store(_shared_db)
|
||||||
|
|
@ -1312,17 +1295,14 @@ def search_async(
|
||||||
user_store.save_listings(listings)
|
user_store.save_listings(listings)
|
||||||
|
|
||||||
seller_ids = list({l.seller_platform_id for l in listings if l.seller_platform_id})
|
seller_ids = list({l.seller_platform_id for l in listings if l.seller_platform_id})
|
||||||
if platform == "ebay":
|
|
||||||
n_cat = shared_store.refresh_seller_categories("ebay", seller_ids, listing_store=user_store)
|
n_cat = shared_store.refresh_seller_categories("ebay", seller_ids, listing_store=user_store)
|
||||||
if n_cat:
|
if n_cat:
|
||||||
log.info("async_search: category history derived for %d sellers", n_cat)
|
log.info("async_search: category history derived for %d sellers", n_cat)
|
||||||
|
|
||||||
staged = user_store.get_listings_staged(platform, [l.platform_listing_id for l in listings])
|
staged = user_store.get_listings_staged("ebay", [l.platform_listing_id for l in listings])
|
||||||
listings = [staged.get(l.platform_listing_id, l) for l in listings]
|
listings = [staged.get(l.platform_listing_id, l) for l in listings]
|
||||||
|
|
||||||
_main_adapter = _make_adapter(shared_store, adapter, platform=platform)
|
_main_adapter = _make_adapter(shared_store, adapter)
|
||||||
sellers_needing_age: list[str] = []
|
|
||||||
if platform == "ebay":
|
|
||||||
sellers_needing_age = [
|
sellers_needing_age = [
|
||||||
l.seller_platform_id for l in listings
|
l.seller_platform_id for l in listings
|
||||||
if l.seller_platform_id
|
if l.seller_platform_id
|
||||||
|
|
@ -1332,7 +1312,7 @@ def search_async(
|
||||||
seen_set: set[str] = set()
|
seen_set: set[str] = set()
|
||||||
sellers_needing_age = [s for s in sellers_needing_age if not (s in seen_set or seen_set.add(s))] # type: ignore[func-returns-value]
|
sellers_needing_age = [s for s in sellers_needing_age if not (s in seen_set or seen_set.add(s))] # type: ignore[func-returns-value]
|
||||||
|
|
||||||
# Use a temporary CloudUser-like object for Trading API enrichment (eBay only)
|
# Use a temporary CloudUser-like object for Trading API enrichment
|
||||||
from api.cloud_session import CloudUser as _CloudUser
|
from api.cloud_session import CloudUser as _CloudUser
|
||||||
_session_stub = _CloudUser(
|
_session_stub = _CloudUser(
|
||||||
user_id=_user_id,
|
user_id=_user_id,
|
||||||
|
|
@ -1340,8 +1320,6 @@ def search_async(
|
||||||
shared_db=_shared_db,
|
shared_db=_shared_db,
|
||||||
user_db=_user_db,
|
user_db=_user_db,
|
||||||
)
|
)
|
||||||
trading_api_enriched: set[str] = set()
|
|
||||||
if platform == "ebay":
|
|
||||||
trading_api_enriched = _try_trading_api_enrichment(
|
trading_api_enriched = _try_trading_api_enrichment(
|
||||||
_main_adapter, sellers_needing_age, _user_db
|
_main_adapter, sellers_needing_age, _user_db
|
||||||
)
|
)
|
||||||
|
|
@ -1356,7 +1334,7 @@ def search_async(
|
||||||
_enqueue_vision_tasks(listings, trust_scores_list, _session_stub)
|
_enqueue_vision_tasks(listings, trust_scores_list, _session_stub)
|
||||||
|
|
||||||
query_hash = _hashlib_local.md5(comp_query.encode()).hexdigest()
|
query_hash = _hashlib_local.md5(comp_query.encode()).hexdigest()
|
||||||
comp = shared_store.get_market_comp(platform, query_hash)
|
comp = shared_store.get_market_comp("ebay", query_hash)
|
||||||
market_price = comp.median_price if comp else None
|
market_price = comp.median_price if comp else None
|
||||||
|
|
||||||
# Store raw listings + market_price in cache (trust scores excluded).
|
# Store raw listings + market_price in cache (trust scores excluded).
|
||||||
|
|
@ -1372,11 +1350,11 @@ def search_async(
|
||||||
}
|
}
|
||||||
seller_map = {
|
seller_map = {
|
||||||
listing.seller_platform_id: dataclasses.asdict(
|
listing.seller_platform_id: dataclasses.asdict(
|
||||||
shared_store.get_seller(platform, listing.seller_platform_id)
|
shared_store.get_seller("ebay", listing.seller_platform_id)
|
||||||
)
|
)
|
||||||
for listing in listings
|
for listing in listings
|
||||||
if listing.seller_platform_id
|
if listing.seller_platform_id
|
||||||
and shared_store.get_seller(platform, listing.seller_platform_id)
|
and shared_store.get_seller("ebay", listing.seller_platform_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
_is_unauthed = _user_id == "anonymous" or _user_id.startswith("guest:")
|
_is_unauthed = _user_id == "anonymous" or _user_id.startswith("guest:")
|
||||||
|
|
@ -1407,17 +1385,12 @@ def search_async(
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
# BTF background enrichment is eBay-specific.
|
# Kick off background enrichment — it pushes "update" events and the sentinel.
|
||||||
if platform == "ebay":
|
|
||||||
_trigger_scraper_enrichment(
|
_trigger_scraper_enrichment(
|
||||||
listings, shared_store, _shared_db,
|
listings, shared_store, _shared_db,
|
||||||
user_db=_user_db, query=comp_query, session_id=session_id,
|
user_db=_user_db, query=comp_query, session_id=session_id,
|
||||||
skip_seller_ids=trading_api_enriched,
|
skip_seller_ids=trading_api_enriched,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# For non-eBay platforms, push the sentinel directly since there's no
|
|
||||||
# background enrichment pass.
|
|
||||||
_push(None)
|
|
||||||
|
|
||||||
except _sqlite3.OperationalError as e:
|
except _sqlite3.OperationalError as e:
|
||||||
log.warning("async_search DB contention: %s", e)
|
log.warning("async_search DB contention: %s", e)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
-- Migration 013: eBay user OAuth tokens
|
|
||||||
--
|
|
||||||
-- Stores per-user eBay Authorization Code tokens so the app can call
|
|
||||||
-- Trading API GetUser for instant account_age_days + category feedback
|
|
||||||
-- without Playwright scraping.
|
|
||||||
--
|
|
||||||
-- Stored in the per-user DB (user.db), never the shared DB.
|
|
||||||
-- access_token is short-lived (2h); refresh_token is valid 18 months.
|
|
||||||
-- The API layer refreshes access_token automatically before expiry.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ebay_user_tokens (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
-- Single row per user DB — upsert on reconnect
|
|
||||||
access_token TEXT NOT NULL,
|
|
||||||
refresh_token TEXT NOT NULL,
|
|
||||||
expires_at REAL NOT NULL, -- epoch seconds; access token expiry
|
|
||||||
scopes TEXT NOT NULL DEFAULT '',
|
|
||||||
connected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
last_refreshed TEXT
|
|
||||||
);
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- Migration 014: background monitor settings on saved_searches + watch_alerts table
|
|
||||||
|
|
||||||
ALTER TABLE saved_searches ADD COLUMN monitor_enabled INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE saved_searches ADD COLUMN poll_interval_min INTEGER NOT NULL DEFAULT 60;
|
|
||||||
ALTER TABLE saved_searches ADD COLUMN min_trust_score INTEGER NOT NULL DEFAULT 60;
|
|
||||||
ALTER TABLE saved_searches ADD COLUMN last_checked_at TEXT;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS watch_alerts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
saved_search_id INTEGER NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE,
|
|
||||||
platform_listing_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
price REAL NOT NULL,
|
|
||||||
currency TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
trust_score INTEGER NOT NULL,
|
|
||||||
url TEXT,
|
|
||||||
first_alerted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
dismissed_at TEXT,
|
|
||||||
UNIQUE(saved_search_id, platform_listing_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_watch_alerts_undismissed
|
|
||||||
ON watch_alerts(saved_search_id)
|
|
||||||
WHERE dismissed_at IS NULL;
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
-- Migration 015: cross-user monitor registry for the background polling loop
|
|
||||||
--
|
|
||||||
-- In cloud mode this table lives in shared.db — the polling loop queries it
|
|
||||||
-- to find all due monitors without scanning per-user DB files.
|
|
||||||
-- In local mode it lives in the single local DB (same result, one user).
|
|
||||||
--
|
|
||||||
-- user_db_path references the per-user snipe user.db so the poller knows
|
|
||||||
-- which DB to open for the full SavedSearch config and to write alerts.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS active_monitors (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_db_path TEXT NOT NULL,
|
|
||||||
saved_search_id INTEGER NOT NULL,
|
|
||||||
poll_interval_min INTEGER NOT NULL DEFAULT 60,
|
|
||||||
last_checked_at TEXT,
|
|
||||||
UNIQUE(user_db_path, saved_search_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_active_monitors_due
|
|
||||||
ON active_monitors(last_checked_at);
|
|
||||||
|
|
@ -81,26 +81,6 @@ class SavedSearch:
|
||||||
id: Optional[int] = None
|
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
|
||||||
|
|
|
||||||
165
app/db/store.py
165
app/db/store.py
|
|
@ -8,7 +8,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, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
@ -310,66 +310,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()
|
||||||
|
|
@ -381,112 +330,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:
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,6 @@ 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 +18,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,9 +1,8 @@
|
||||||
"""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
|
||||||
|
|
@ -211,70 +210,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:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ long-lived Playwright browser instances with fresh contexts ready to serve.
|
||||||
Key design:
|
Key design:
|
||||||
- Pool slots: ``(xvfb_proc, pw_instance, browser, context, display_num, last_used_ts)``
|
- Pool slots: ``(xvfb_proc, pw_instance, browser, context, display_num, last_used_ts)``
|
||||||
One headed Chromium browser per slot — keeps the Kasada fingerprint clean.
|
One headed Chromium browser per slot — keeps the Kasada fingerprint clean.
|
||||||
- Display numbering: :200-:399 (avoids host :0 and low-numbered kernel socket conflicts).
|
|
||||||
- Thread safety: ``queue.Queue`` with blocking get (timeout=3s before fresh fallback).
|
- Thread safety: ``queue.Queue`` with blocking get (timeout=3s before fresh fallback).
|
||||||
- Replenishment: after each use, the dirty context is closed and a new context is
|
- Replenishment: after each use, the dirty context is closed and a new context is
|
||||||
opened on the *same* browser, then returned to the queue. Browser launch overhead
|
opened on the *same* browser, then returned to the queue. Browser launch overhead
|
||||||
|
|
@ -34,17 +33,15 @@ from typing import Optional
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Display counter shared by pool warmup and _fetch_fresh fallback.
|
# Reuse the same display counter namespace as scraper.py to avoid collisions.
|
||||||
# Range :200-:399 avoids low-numbered displays that may be pre-occupied by
|
# Pool uses :100-:199; scraper.py fallback uses :200-:299.
|
||||||
# the host X server or lingering kernel sockets from previous runs.
|
_pool_display_counter = itertools.cycle(range(100, 200))
|
||||||
_pool_display_counter = itertools.cycle(range(200, 400))
|
|
||||||
|
|
||||||
_IDLE_TIMEOUT_SECS = 300 # 5 minutes
|
_IDLE_TIMEOUT_SECS = 300 # 5 minutes
|
||||||
_CLEANUP_INTERVAL_SECS = 60
|
_CLEANUP_INTERVAL_SECS = 60
|
||||||
_QUEUE_TIMEOUT_SECS = 3.0
|
_QUEUE_TIMEOUT_SECS = 3.0
|
||||||
|
|
||||||
_CHROMIUM_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]
|
_CHROMIUM_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]
|
||||||
_XVFB_ARGS = ["-screen", "0", "1280x800x24", "-ac"] # -ac: disable X auth (safe in isolated Docker)
|
|
||||||
_USER_AGENT = (
|
_USER_AGENT = (
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||||
|
|
@ -77,7 +74,7 @@ def _launch_slot() -> "_PooledBrowser":
|
||||||
env["DISPLAY"] = display
|
env["DISPLAY"] = display
|
||||||
|
|
||||||
xvfb = subprocess.Popen(
|
xvfb = subprocess.Popen(
|
||||||
["Xvfb", display] + _XVFB_ARGS,
|
["Xvfb", display, "-screen", "0", "1280x800x24"],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
|
@ -233,13 +230,7 @@ class BrowserPool:
|
||||||
# Core fetch
|
# Core fetch
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def fetch_html(
|
def fetch_html(self, url: str, delay: float = 1.0) -> str:
|
||||||
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.
|
"""Navigate to *url* and return the rendered HTML.
|
||||||
|
|
||||||
Borrows a browser context from the pool (blocks up to 3s), uses it to
|
Borrows a browser context from the pool (blocks up to 3s), uses it to
|
||||||
|
|
@ -247,15 +238,6 @@ class BrowserPool:
|
||||||
|
|
||||||
Falls back to a fully fresh browser if the pool is empty after the
|
Falls back to a fully fresh browser if the pool is empty after the
|
||||||
timeout or if Playwright is unavailable.
|
timeout or if Playwright is unavailable.
|
||||||
|
|
||||||
Args:
|
|
||||||
wait_for_selector: CSS/data-testid selector to wait for before capturing
|
|
||||||
HTML (e.g. ``"[data-testid='SearchResults']"``). When set, the fixed
|
|
||||||
*wait_for_timeout_ms* sleep is skipped — the page is captured as soon
|
|
||||||
as the selector appears (or after 15s timeout, whichever comes first).
|
|
||||||
wait_for_timeout_ms: static post-navigation sleep in ms when
|
|
||||||
*wait_for_selector* is None. Default 2000; set higher (e.g. 8000)
|
|
||||||
for sites with JS challenge pages (Cloudflare Turnstile).
|
|
||||||
"""
|
"""
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
|
|
||||||
|
|
@ -267,11 +249,7 @@ class BrowserPool:
|
||||||
|
|
||||||
if slot is not None:
|
if slot is not None:
|
||||||
try:
|
try:
|
||||||
html = self._fetch_with_slot(
|
html = self._fetch_with_slot(slot, url)
|
||||||
slot, url,
|
|
||||||
wait_for_selector=wait_for_selector,
|
|
||||||
wait_for_timeout_ms=wait_for_timeout_ms,
|
|
||||||
)
|
|
||||||
# Replenish: close dirty context, open fresh one, return to queue.
|
# Replenish: close dirty context, open fresh one, return to queue.
|
||||||
try:
|
try:
|
||||||
fresh_slot = _replenish_slot(slot)
|
fresh_slot = _replenish_slot(slot)
|
||||||
|
|
@ -286,11 +264,7 @@ class BrowserPool:
|
||||||
# Fall through to fresh browser below.
|
# Fall through to fresh browser below.
|
||||||
|
|
||||||
# Fallback: fresh browser (same code as old scraper._fetch_url).
|
# Fallback: fresh browser (same code as old scraper._fetch_url).
|
||||||
return self._fetch_fresh(
|
return self._fetch_fresh(url)
|
||||||
url,
|
|
||||||
wait_for_selector=wait_for_selector,
|
|
||||||
wait_for_timeout_ms=wait_for_timeout_ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
|
|
@ -308,13 +282,7 @@ class BrowserPool:
|
||||||
self._playwright_available = False
|
self._playwright_available = False
|
||||||
return self._playwright_available
|
return self._playwright_available
|
||||||
|
|
||||||
def _fetch_with_slot(
|
def _fetch_with_slot(self, slot: _PooledBrowser, url: str) -> str:
|
||||||
self,
|
|
||||||
slot: _PooledBrowser,
|
|
||||||
url: str,
|
|
||||||
wait_for_selector: Optional[str] = None,
|
|
||||||
wait_for_timeout_ms: int = 2000,
|
|
||||||
) -> str:
|
|
||||||
"""Open a new page on *slot.ctx*, navigate to *url*, return HTML."""
|
"""Open a new page on *slot.ctx*, navigate to *url*, return HTML."""
|
||||||
from playwright_stealth import Stealth
|
from playwright_stealth import Stealth
|
||||||
|
|
||||||
|
|
@ -322,13 +290,7 @@ class BrowserPool:
|
||||||
try:
|
try:
|
||||||
Stealth().apply_stealth_sync(page)
|
Stealth().apply_stealth_sync(page)
|
||||||
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||||
if wait_for_selector:
|
page.wait_for_timeout(2000)
|
||||||
try:
|
|
||||||
page.wait_for_selector(wait_for_selector, timeout=15_000)
|
|
||||||
except Exception:
|
|
||||||
pass # selector didn't appear; return whatever loaded
|
|
||||||
else:
|
|
||||||
page.wait_for_timeout(wait_for_timeout_ms)
|
|
||||||
return page.content()
|
return page.content()
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
|
@ -336,12 +298,7 @@ class BrowserPool:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _fetch_fresh(
|
def _fetch_fresh(self, url: str) -> str:
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
wait_for_selector: Optional[str] = None,
|
|
||||||
wait_for_timeout_ms: int = 2000,
|
|
||||||
) -> str:
|
|
||||||
"""Launch a fully fresh browser, fetch *url*, close everything."""
|
"""Launch a fully fresh browser, fetch *url*, close everything."""
|
||||||
import subprocess as _subprocess
|
import subprocess as _subprocess
|
||||||
|
|
||||||
|
|
@ -350,7 +307,7 @@ class BrowserPool:
|
||||||
from playwright_stealth import Stealth
|
from playwright_stealth import Stealth
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Playwright not installed — cannot fetch pages. "
|
"Playwright not installed — cannot fetch eBay pages. "
|
||||||
"Install playwright and playwright-stealth in the Docker image."
|
"Install playwright and playwright-stealth in the Docker image."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
@ -360,11 +317,10 @@ class BrowserPool:
|
||||||
env["DISPLAY"] = display
|
env["DISPLAY"] = display
|
||||||
|
|
||||||
xvfb = _subprocess.Popen(
|
xvfb = _subprocess.Popen(
|
||||||
["Xvfb", display] + _XVFB_ARGS,
|
["Xvfb", display, "-screen", "0", "1280x800x24"],
|
||||||
stdout=_subprocess.DEVNULL,
|
stdout=_subprocess.DEVNULL,
|
||||||
stderr=_subprocess.DEVNULL,
|
stderr=_subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
time.sleep(0.3) # wait for Xvfb to bind the display socket before Chromium starts
|
|
||||||
try:
|
try:
|
||||||
with sync_playwright() as pw:
|
with sync_playwright() as pw:
|
||||||
browser = pw.chromium.launch(
|
browser = pw.chromium.launch(
|
||||||
|
|
@ -379,13 +335,7 @@ class BrowserPool:
|
||||||
page = ctx.new_page()
|
page = ctx.new_page()
|
||||||
Stealth().apply_stealth_sync(page)
|
Stealth().apply_stealth_sync(page)
|
||||||
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||||
if wait_for_selector:
|
page.wait_for_timeout(2000)
|
||||||
try:
|
|
||||||
page.wait_for_selector(wait_for_selector, timeout=15_000)
|
|
||||||
except Exception:
|
|
||||||
pass # selector didn't appear; return whatever loaded
|
|
||||||
else:
|
|
||||||
page.wait_for_timeout(wait_for_timeout_ms)
|
|
||||||
html = page.content()
|
html = page.content()
|
||||||
browser.close()
|
browser.close()
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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,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)
|
|
||||||
|
|
@ -153,10 +153,7 @@ class TestFetchHtmlPoolHit:
|
||||||
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=test", delay=0)
|
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=test", delay=0)
|
||||||
|
|
||||||
assert html == "<html>ok</html>"
|
assert html == "<html>ok</html>"
|
||||||
mock_fetch.assert_called_once_with(
|
mock_fetch.assert_called_once_with(slot, "https://www.ebay.com/sch/i.html?_nkw=test")
|
||||||
slot, "https://www.ebay.com/sch/i.html?_nkw=test",
|
|
||||||
wait_for_selector=None, wait_for_timeout_ms=2000,
|
|
||||||
)
|
|
||||||
mock_replenish.assert_called_once_with(slot)
|
mock_replenish.assert_called_once_with(slot)
|
||||||
# Fresh slot returned to queue
|
# Fresh slot returned to queue
|
||||||
assert pool._q.get_nowait() is fresh_slot
|
assert pool._q.get_nowait() is fresh_slot
|
||||||
|
|
@ -200,10 +197,7 @@ class TestFetchHtmlFallback:
|
||||||
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=widget", delay=0)
|
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=widget", delay=0)
|
||||||
|
|
||||||
assert html == "<html>fresh</html>"
|
assert html == "<html>fresh</html>"
|
||||||
mock_fresh.assert_called_once_with(
|
mock_fresh.assert_called_once_with("https://www.ebay.com/sch/i.html?_nkw=widget")
|
||||||
"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):
|
def test_falls_back_when_pooled_fetch_raises(self):
|
||||||
"""If _fetch_with_slot raises, the slot is closed and _fetch_fresh is used."""
|
"""If _fetch_with_slot raises, the slot is closed and _fetch_fresh is used."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -21,7 +21,6 @@ import { useMotion } from './composables/useMotion'
|
||||||
import { useSnipeMode } from './composables/useSnipeMode'
|
import { useSnipeMode } from './composables/useSnipeMode'
|
||||||
import { useTheme } from './composables/useTheme'
|
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 { usePreferencesStore } from './stores/preferences'
|
||||||
|
|
@ -32,8 +31,6 @@ 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: restoreTheme } = useTheme()
|
||||||
const { restore: restoreCandy, useWordTrigger } = useCandycoreMode()
|
|
||||||
useWordTrigger()
|
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const blocklistStore = useBlocklistStore()
|
const blocklistStore = useBlocklistStore()
|
||||||
const preferencesStore = usePreferencesStore()
|
const preferencesStore = usePreferencesStore()
|
||||||
|
|
@ -45,7 +42,6 @@ useKonamiCode(activate)
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
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
|
restoreTheme() // re-apply explicit theme override on hard reload
|
||||||
restoreCandy() // re-apply candycore mode from localStorage on hard reload
|
|
||||||
await session.bootstrap() // fetch tier + feature flags from API
|
await session.bootstrap() // fetch tier + feature flags from API
|
||||||
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
|
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
|
||||||
preferencesStore.load() // load user preferences after session resolves
|
preferencesStore.load() // load user preferences after session resolves
|
||||||
|
|
@ -61,12 +57,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);
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
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 */
|
/* Explicit dark override — beats OS preference when user forces dark in Settings */
|
||||||
[data-theme="dark"]:not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
[data-theme="dark"]:not([data-snipe-mode="active"]) {
|
||||||
--color-surface: #0d1117;
|
--color-surface: #0d1117;
|
||||||
--color-surface-2: #161b22;
|
--color-surface-2: #161b22;
|
||||||
--color-surface-raised: #1c2129;
|
--color-surface-raised: #1c2129;
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@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-theme="dark"]):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;
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Explicit light override — beats OS preference when user forces light in Settings */
|
/* Explicit light override — beats OS preference when user forces light in Settings */
|
||||||
[data-theme="light"]:not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
[data-theme="light"]:not([data-snipe-mode="active"]) {
|
||||||
--color-surface: #f8f5ee;
|
--color-surface: #f8f5ee;
|
||||||
--color-surface-2: #f0ece3;
|
--color-surface-2: #f0ece3;
|
||||||
--color-surface-raised: #e8e3d8;
|
--color-surface-raised: #e8e3d8;
|
||||||
|
|
@ -178,56 +178,6 @@
|
||||||
--shadow-lg: 0 10px 30px rgba(60,45,20,0.2), 0 4px 8px 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' */
|
||||||
|
|
|
||||||
|
|
@ -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,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 }
|
|
||||||
}
|
|
||||||
|
|
@ -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,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 }
|
|
||||||
})
|
|
||||||
|
|
@ -59,11 +59,6 @@ export interface SavedSearch {
|
||||||
filters_json: string // JSON blob of SearchFilters subset
|
filters_json: string // JSON blob of SearchFilters subset
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
last_run_at: string | null
|
last_run_at: string | null
|
||||||
// Monitor settings (migration 014)
|
|
||||||
monitor_enabled: boolean
|
|
||||||
poll_interval_min: number
|
|
||||||
min_trust_score: number
|
|
||||||
last_checked_at: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchParamsResult {
|
export interface SearchParamsResult {
|
||||||
|
|
@ -98,7 +93,6 @@ export interface SearchFilters {
|
||||||
mustExclude?: string // comma-separated; forwarded to eBay -term AND client-side
|
mustExclude?: string // comma-separated; forwarded to eBay -term AND client-side
|
||||||
categoryId?: string // eBay category ID (e.g. "27386" = Graphics/Video Cards)
|
categoryId?: string // eBay category ID (e.g. "27386" = Graphics/Video Cards)
|
||||||
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
|
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
|
||||||
platform?: string // target platform; defaults to 'ebay' when omitted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session cache ─────────────────────────────────────────────────────────────
|
// ── Session cache ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -179,7 +173,6 @@ export const useSearchStore = defineStore('search', () => {
|
||||||
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
|
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
|
||||||
if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim())
|
if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim())
|
||||||
if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter)
|
if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter)
|
||||||
if (filters.platform && filters.platform !== 'ebay') params.set('platform', filters.platform)
|
|
||||||
|
|
||||||
// Use the async endpoint: returns 202 immediately with a session_id, then
|
// Use the async endpoint: returns 202 immediately with a session_id, then
|
||||||
// streams listings + trust scores via SSE as the scrape completes.
|
// streams listings + trust scores via SSE as the scrape completes.
|
||||||
|
|
|
||||||
|
|
@ -31,63 +31,8 @@
|
||||||
<span v-if="item.last_run_at">Last run {{ formatDate(item.last_run_at) }}</span>
|
<span v-if="item.last_run_at">Last run {{ formatDate(item.last_run_at) }}</span>
|
||||||
<span v-else>Never run</span>
|
<span v-else>Never run</span>
|
||||||
· Saved {{ formatDate(item.created_at) }}
|
· Saved {{ formatDate(item.created_at) }}
|
||||||
<span v-if="item.last_checked_at" class="saved-card-checked">
|
|
||||||
· Monitored {{ formatDate(item.last_checked_at) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="saved-card-right">
|
|
||||||
<!-- Monitor toggle — only shown to paid+ users -->
|
|
||||||
<div v-if="session.isPaid || session.tier === 'local'" class="monitor-section">
|
|
||||||
<label class="monitor-toggle-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="monitor-toggle-input"
|
|
||||||
:checked="item.monitor_enabled"
|
|
||||||
:aria-label="`Monitor ${item.name}`"
|
|
||||||
@change="onToggleMonitor(item, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span class="monitor-toggle-track" aria-hidden="true" />
|
|
||||||
<span class="monitor-toggle-text">Monitor</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Inline settings — only when enabled -->
|
|
||||||
<Transition name="slide">
|
|
||||||
<div v-if="item.monitor_enabled" class="monitor-settings">
|
|
||||||
<label class="monitor-setting-label">
|
|
||||||
Check every
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="monitor-setting-input"
|
|
||||||
:value="item.poll_interval_min"
|
|
||||||
min="15"
|
|
||||||
max="1440"
|
|
||||||
step="15"
|
|
||||||
:aria-label="`Poll interval for ${item.name} in minutes`"
|
|
||||||
@change="onIntervalChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
|
|
||||||
/>
|
|
||||||
min
|
|
||||||
<span class="monitor-hint">Min 15. 60 = hourly.</span>
|
|
||||||
</label>
|
|
||||||
<label class="monitor-setting-label">
|
|
||||||
Trust ≥
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="monitor-setting-input"
|
|
||||||
:value="item.min_trust_score"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="5"
|
|
||||||
:aria-label="`Minimum trust score for ${item.name}`"
|
|
||||||
@change="onThresholdChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
|
|
||||||
/>
|
|
||||||
<span class="monitor-hint">0–100. 60 = medium confidence.</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="saved-card-actions">
|
<div class="saved-card-actions">
|
||||||
<button class="saved-run-btn" type="button" @click="onRun(item)">
|
<button class="saved-run-btn" type="button" @click="onRun(item)">
|
||||||
Run
|
Run
|
||||||
|
|
@ -96,47 +41,31 @@
|
||||||
class="saved-delete-btn"
|
class="saved-delete-btn"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="`Delete saved search: ${item.name}`"
|
:aria-label="`Delete saved search: ${item.name}`"
|
||||||
@click="onDelete(item)"
|
@click="onDelete(item.id)"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Undo toast for delete -->
|
|
||||||
<Transition name="toast">
|
|
||||||
<div v-if="pendingDelete" class="undo-toast" role="status" aria-live="polite">
|
|
||||||
<span>Deleted "{{ pendingDelete.name }}"</span>
|
|
||||||
<button class="undo-btn" @click="onUndoDelete">Undo</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useSavedSearchesStore } from '../stores/savedSearches'
|
import { useSavedSearchesStore } from '../stores/savedSearches'
|
||||||
import { useSessionStore } from '../stores/session'
|
|
||||||
import type { SavedSearch } from '../stores/savedSearches'
|
import type { SavedSearch } from '../stores/savedSearches'
|
||||||
|
|
||||||
const store = useSavedSearchesStore()
|
const store = useSavedSearchesStore()
|
||||||
const session = useSessionStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const BASE = import.meta.env.VITE_API_BASE ?? ''
|
|
||||||
|
|
||||||
// Soft-delete state — holds for 3 seconds before committing
|
|
||||||
const pendingDelete = ref<SavedSearch | null>(null)
|
|
||||||
let deleteTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
onMounted(() => store.fetchAll())
|
onMounted(() => store.fetchAll())
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
function formatDate(iso: string | null): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRun(item: SavedSearch) {
|
async function onRun(item: SavedSearch) {
|
||||||
|
|
@ -146,65 +75,8 @@ async function onRun(item: SavedSearch) {
|
||||||
router.push({ path: '/', query })
|
router.push({ path: '/', query })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDelete(item: SavedSearch) {
|
async function onDelete(id: number) {
|
||||||
// Soft-delete: show undo toast, commit after 3s.
|
await store.remove(id)
|
||||||
if (deleteTimer) clearTimeout(deleteTimer)
|
|
||||||
pendingDelete.value = item
|
|
||||||
deleteTimer = setTimeout(async () => {
|
|
||||||
if (pendingDelete.value?.id === item.id) {
|
|
||||||
await store.remove(item.id)
|
|
||||||
pendingDelete.value = null
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUndoDelete() {
|
|
||||||
if (deleteTimer) clearTimeout(deleteTimer)
|
|
||||||
pendingDelete.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onToggleMonitor(item: SavedSearch, enabled: boolean) {
|
|
||||||
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
monitor_enabled: enabled,
|
|
||||||
poll_interval_min: item.poll_interval_min,
|
|
||||||
min_trust_score: item.min_trust_score,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await store.fetchAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onIntervalChange(item: SavedSearch, minutes: number) {
|
|
||||||
if (isNaN(minutes) || minutes < 15) return
|
|
||||||
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
monitor_enabled: item.monitor_enabled,
|
|
||||||
poll_interval_min: minutes,
|
|
||||||
min_trust_score: item.min_trust_score,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await store.fetchAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onThresholdChange(item: SavedSearch, score: number) {
|
|
||||||
if (isNaN(score)) return
|
|
||||||
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
monitor_enabled: item.monitor_enabled,
|
|
||||||
poll_interval_min: item.poll_interval_min,
|
|
||||||
min_trust_score: score,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await store.fetchAll()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -255,12 +127,12 @@ async function onThresholdChange(item: SavedSearch, score: number) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
max-width: 800px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-card {
|
.saved-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
|
|
@ -302,131 +174,13 @@ async function onThresholdChange(item: SavedSearch, score: number) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-card-checked {
|
|
||||||
color: var(--app-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right column: monitor section + action buttons */
|
|
||||||
.saved-card-right {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved-card-actions {
|
.saved-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
|
||||||
|
|
||||||
/* Monitor toggle */
|
|
||||||
.monitor-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-toggle-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Visually hide the native checkbox but keep it accessible */
|
|
||||||
.monitor-toggle-input {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-toggle-track {
|
|
||||||
display: inline-block;
|
|
||||||
width: 32px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--color-border);
|
|
||||||
position: relative;
|
|
||||||
transition: background 150ms ease;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monitor-toggle-track::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
transition: transform 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-toggle-input:checked + .monitor-toggle-track {
|
|
||||||
background: var(--app-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-toggle-input:checked + .monitor-toggle-track::after {
|
|
||||||
transform: translateX(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus ring on the label when the hidden checkbox is focused */
|
|
||||||
.monitor-toggle-label:has(.monitor-toggle-input:focus-visible) .monitor-toggle-track {
|
|
||||||
outline: 2px solid var(--app-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-toggle-text {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline monitor settings */
|
|
||||||
.monitor-settings {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-setting-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-setting-input {
|
|
||||||
width: 60px;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-hint {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved-run-btn {
|
.saved-run-btn {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
background: var(--app-primary);
|
background: var(--app-primary);
|
||||||
|
|
@ -452,65 +206,13 @@ async function onThresholdChange(item: SavedSearch, score: number) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 150ms ease, color 150ms ease;
|
transition: border-color 150ms ease, color 150ms ease;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
min-height: 28px;
|
|
||||||
}
|
}
|
||||||
.saved-delete-btn:hover { border-color: var(--color-error); color: var(--color-error); }
|
.saved-delete-btn:hover { border-color: var(--color-error); color: var(--color-error); }
|
||||||
|
|
||||||
/* Undo toast */
|
|
||||||
.undo-toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: calc(var(--space-6) + env(safe-area-inset-bottom));
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3) var(--space-5);
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
z-index: 300;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.undo-btn {
|
|
||||||
padding: var(--space-1) var(--space-3);
|
|
||||||
background: var(--app-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
.slide-enter-active,
|
|
||||||
.slide-leave-active { transition: opacity 150ms ease, max-height 200ms ease; max-height: 200px; overflow: hidden; }
|
|
||||||
.slide-enter-from,
|
|
||||||
.slide-leave-to { opacity: 0; max-height: 0; }
|
|
||||||
|
|
||||||
.toast-enter-active,
|
|
||||||
.toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
|
|
||||||
.toast-enter-from,
|
|
||||||
.toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.slide-enter-active, .slide-leave-active,
|
|
||||||
.toast-enter-active, .toast-leave-active { transition: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.saved-header { padding: var(--space-4); }
|
.saved-header { padding: var(--space-4); }
|
||||||
.saved-list { padding: var(--space-4); }
|
.saved-list { padding: var(--space-4); }
|
||||||
.saved-card { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
.saved-card { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
||||||
.saved-card-right { width: 100%; align-items: flex-start; }
|
|
||||||
.saved-card-actions { width: 100%; justify-content: flex-end; }
|
.saved-card-actions { width: 100%; justify-content: flex-end; }
|
||||||
.monitor-section { width: 100%; align-items: flex-start; }
|
|
||||||
.monitor-settings { width: 100%; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,8 @@
|
||||||
<div class="search-view">
|
<div class="search-view">
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
<header class="search-header">
|
<header class="search-header">
|
||||||
<div class="platform-tabs" role="tablist" aria-label="Search platform">
|
|
||||||
<button
|
|
||||||
v-for="p in PLATFORMS"
|
|
||||||
:key="p.value"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
class="platform-tab"
|
|
||||||
:class="{
|
|
||||||
'platform-tab--active': filters.platform === p.value,
|
|
||||||
'platform-tab--soon': !p.available,
|
|
||||||
}"
|
|
||||||
:aria-selected="filters.platform === p.value"
|
|
||||||
:disabled="!p.available"
|
|
||||||
:title="p.available ? p.label : `${p.label} — coming soon`"
|
|
||||||
@click="p.available && (filters.platform = p.value)"
|
|
||||||
>
|
|
||||||
{{ p.label }}
|
|
||||||
<span v-if="!p.available" class="platform-tab__soon">soon</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form class="search-form" @submit.prevent="onSearch" role="search">
|
<form class="search-form" @submit.prevent="onSearch" role="search">
|
||||||
<div class="search-form-row1">
|
<div class="search-form-row1">
|
||||||
<template v-if="filters.platform === 'ebay' || !filters.platform">
|
|
||||||
<label for="cat-select" class="sr-only">Category</label>
|
<label for="cat-select" class="sr-only">Category</label>
|
||||||
<select
|
<select
|
||||||
id="cat-select"
|
id="cat-select"
|
||||||
|
|
@ -41,7 +20,6 @@
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</template>
|
|
||||||
<label for="search-input" class="sr-only">Search listings</label>
|
<label for="search-input" class="sr-only">Search listings</label>
|
||||||
<input
|
<input
|
||||||
id="search-input"
|
id="search-input"
|
||||||
|
|
@ -138,7 +116,6 @@
|
||||||
|
|
||||||
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
||||||
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
||||||
<template v-if="filters.platform === 'ebay' || !filters.platform">
|
|
||||||
<h2 class="filter-section-heading filter-section-heading--search">
|
<h2 class="filter-section-heading filter-section-heading--search">
|
||||||
eBay Search
|
eBay Search
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -239,7 +216,6 @@
|
||||||
<p class="filter-pages-hint">Excludes forwarded to eBay on re-search</p>
|
<p class="filter-pages-hint">Excludes forwarded to eBay on re-search</p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Post-search Filters ────────────────────────────────────────── -->
|
<!-- ── Post-search Filters ────────────────────────────────────────── -->
|
||||||
<!-- Applied locally to current results — no re-search needed. -->
|
<!-- Applied locally to current results — no re-search needed. -->
|
||||||
|
|
@ -379,9 +355,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading (scraping in progress, no results yet) -->
|
|
||||||
<SearchProgress v-else-if="store.loading && !store.results.length" :query="store.query" :platform="filters.platform ?? 'ebay'" />
|
|
||||||
|
|
||||||
<!-- No results -->
|
<!-- No results -->
|
||||||
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
|
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
|
||||||
<p>No listings found for <strong>{{ store.query }}</strong>.</p>
|
<p>No listings found for <strong>{{ store.query }}</strong>.</p>
|
||||||
|
|
@ -402,13 +375,8 @@
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<!-- Re-search indicator — loading while stale results are still visible -->
|
|
||||||
<span v-if="store.loading && store.results.length" class="enriching-badge enriching-badge--searching" aria-live="polite" title="Fetching new results…">
|
|
||||||
<span class="enriching-dot" aria-hidden="true"></span>
|
|
||||||
Re-searching…
|
|
||||||
</span>
|
|
||||||
<!-- Live enrichment indicator — visible while SSE stream is open -->
|
<!-- Live enrichment indicator — visible while SSE stream is open -->
|
||||||
<span v-else-if="store.enriching" class="enriching-badge" aria-live="polite" title="Scores updating as seller data arrives">
|
<span v-if="store.enriching" class="enriching-badge" aria-live="polite" title="Scores updating as seller data arrives">
|
||||||
<span class="enriching-dot" aria-hidden="true"></span>
|
<span class="enriching-dot" aria-hidden="true"></span>
|
||||||
Updating scores…
|
Updating scores…
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -488,7 +456,6 @@ import { useBlocklistStore } from '../stores/blocklist'
|
||||||
import { useReportedStore } from '../stores/reported'
|
import { useReportedStore } from '../stores/reported'
|
||||||
import ListingCard from '../components/ListingCard.vue'
|
import ListingCard from '../components/ListingCard.vue'
|
||||||
import LLMQueryPanel from '../components/LLMQueryPanel.vue'
|
import LLMQueryPanel from '../components/LLMQueryPanel.vue'
|
||||||
import SearchProgress from '../components/SearchProgress.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useSearchStore()
|
const store = useSearchStore()
|
||||||
|
|
@ -660,7 +627,6 @@ const DEFAULT_FILTERS: SearchFilters = {
|
||||||
mustExclude: '',
|
mustExclude: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
||||||
platform: 'ebay',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = reactive<SearchFilters>({ ...DEFAULT_FILTERS })
|
const filters = reactive<SearchFilters>({ ...DEFAULT_FILTERS })
|
||||||
|
|
@ -696,12 +662,6 @@ const parsedMustIncludeGroups = computed(() =>
|
||||||
.filter(g => g.length > 0)
|
.filter(g => g.length > 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
const PLATFORMS: { value: string; label: string; available: boolean }[] = [
|
|
||||||
{ value: 'ebay', label: 'eBay', available: true },
|
|
||||||
{ value: 'mercari', label: 'Mercari', available: true },
|
|
||||||
{ value: 'poshmark', label: 'Poshmark', available: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
const INCLUDE_MODES: { value: MustIncludeMode; label: string }[] = [
|
const INCLUDE_MODES: { value: MustIncludeMode; label: string }[] = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'any', label: 'Any' },
|
{ value: 'any', label: 'Any' },
|
||||||
|
|
@ -1480,16 +1440,6 @@ async function onSearch() {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.enriching-badge--searching {
|
|
||||||
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
|
||||||
border-color: color-mix(in srgb, var(--color-info) 30%, transparent);
|
|
||||||
color: var(--color-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.enriching-badge--searching .enriching-dot {
|
|
||||||
background: var(--color-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.enriching-dot {
|
.enriching-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|
@ -1826,53 +1776,4 @@ async function onSearch() {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Platform tab strip ──────────────────────────────────────────────── */
|
|
||||||
.platform-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-1);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-tab {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-1) var(--space-3);
|
|
||||||
background: transparent;
|
|
||||||
border: 1.5px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-tab:hover:not(:disabled):not(.platform-tab--active) {
|
|
||||||
border-color: var(--app-primary);
|
|
||||||
color: var(--app-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-tab--active {
|
|
||||||
background: var(--app-primary);
|
|
||||||
border-color: var(--app-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-tab--soon {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-tab__soon {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -93,74 +93,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- eBay Account Connection — paid+ only -->
|
|
||||||
<section v-if="ebay.oauth_available && session.isLoggedIn" class="settings-section">
|
|
||||||
<h2 class="settings-section-title">eBay Account</h2>
|
|
||||||
|
|
||||||
<!-- Connected state -->
|
|
||||||
<div v-if="ebay.connected" class="ebay-connected">
|
|
||||||
<div class="ebay-status-row">
|
|
||||||
<span class="ebay-status-dot ebay-status-dot--on" aria-hidden="true" />
|
|
||||||
<span class="settings-toggle-label">Connected</span>
|
|
||||||
</div>
|
|
||||||
<p class="settings-toggle-desc">
|
|
||||||
Snipe uses your eBay account to fetch seller registration dates instantly
|
|
||||||
via the Trading API, without Playwright scraping. This means faster, more
|
|
||||||
accurate trust scores on every search.
|
|
||||||
<span v-if="ebay.access_token_expired" class="ebay-warn">
|
|
||||||
Your access token has expired — reconnect to restore instant enrichment.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<div class="ebay-action-row">
|
|
||||||
<button
|
|
||||||
v-if="ebay.access_token_expired"
|
|
||||||
class="ebay-btn ebay-btn--primary"
|
|
||||||
:disabled="ebay.connecting"
|
|
||||||
@click="startConnect"
|
|
||||||
>
|
|
||||||
Reconnect eBay account
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="ebay-btn ebay-btn--danger"
|
|
||||||
:disabled="ebay.disconnecting"
|
|
||||||
@click="disconnect"
|
|
||||||
>
|
|
||||||
{{ ebay.disconnecting ? 'Disconnecting…' : 'Disconnect' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Not connected — paid tier -->
|
|
||||||
<div v-else-if="session.isPaid || session.isPremium" class="ebay-disconnected">
|
|
||||||
<p class="settings-toggle-desc">
|
|
||||||
Connect your eBay account to enable instant seller registration date lookup
|
|
||||||
via the Trading API. Without it, Snipe falls back to slower Playwright
|
|
||||||
scraping (or Shopping API rate-limited calls) to determine account age.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="ebay-btn ebay-btn--primary"
|
|
||||||
:disabled="ebay.connecting"
|
|
||||||
@click="startConnect"
|
|
||||||
>
|
|
||||||
{{ ebay.connecting ? 'Redirecting to eBay…' : 'Connect eBay account' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Not connected — free tier upsell -->
|
|
||||||
<div v-else class="ebay-disconnected">
|
|
||||||
<p class="settings-toggle-desc">
|
|
||||||
Connect your eBay account for instant seller trust scoring without scraping.
|
|
||||||
Available on Paid tier and above.
|
|
||||||
</p>
|
|
||||||
<a class="ebay-btn ebay-btn--upsell" href="/pricing" rel="noopener">
|
|
||||||
Upgrade to Paid
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="ebay.error" class="settings-error" role="alert">{{ ebay.error }}</p>
|
|
||||||
<p v-if="ebay.success" class="settings-success" role="status">{{ ebay.success }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Affiliate Links — only shown to signed-in cloud users -->
|
<!-- Affiliate Links — only shown to signed-in cloud users -->
|
||||||
<section v-if="session.isLoggedIn" class="settings-section">
|
<section v-if="session.isLoggedIn" class="settings-section">
|
||||||
<h2 class="settings-section-title">Affiliate Links</h2>
|
<h2 class="settings-section-title">Affiliate Links</h2>
|
||||||
|
|
@ -242,16 +174,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, reactive, onMounted } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import { useSessionStore } from '../stores/session'
|
import { useSessionStore } from '../stores/session'
|
||||||
import { usePreferencesStore } from '../stores/preferences'
|
import { usePreferencesStore } from '../stores/preferences'
|
||||||
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
|
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
|
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
|
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
|
||||||
|
|
@ -283,90 +212,6 @@ watch(() => prefs.affiliateByokId, (val) => { byokInput.value = val })
|
||||||
function saveByokId() {
|
function saveByokId() {
|
||||||
prefs.setAffiliateByokId(byokInput.value)
|
prefs.setAffiliateByokId(byokInput.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── eBay Account Connection ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ebay = reactive({
|
|
||||||
oauth_available: false,
|
|
||||||
connected: false,
|
|
||||||
access_token_expired: false,
|
|
||||||
scopes: [] as string[],
|
|
||||||
connecting: false,
|
|
||||||
disconnecting: false,
|
|
||||||
error: '',
|
|
||||||
success: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
async function fetchEbayStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ebay/status')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
ebay.oauth_available = data.oauth_available ?? false
|
|
||||||
ebay.connected = data.connected ?? false
|
|
||||||
ebay.access_token_expired = data.access_token_expired ?? false
|
|
||||||
ebay.scopes = data.scopes ?? []
|
|
||||||
} catch {
|
|
||||||
// silently ignore — section stays hidden if fetch fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startConnect() {
|
|
||||||
ebay.connecting = true
|
|
||||||
ebay.error = ''
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ebay/connect')
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
ebay.error = body.detail ?? 'eBay connection unavailable.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { auth_url } = await res.json()
|
|
||||||
window.location.href = auth_url
|
|
||||||
} catch {
|
|
||||||
ebay.error = 'Could not reach the server. Try again.'
|
|
||||||
ebay.connecting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disconnect() {
|
|
||||||
ebay.disconnecting = true
|
|
||||||
ebay.error = ''
|
|
||||||
ebay.success = ''
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ebay/disconnect', { method: 'DELETE' })
|
|
||||||
if (res.ok || res.status === 204) {
|
|
||||||
ebay.connected = false
|
|
||||||
ebay.access_token_expired = false
|
|
||||||
ebay.scopes = []
|
|
||||||
ebay.success = 'eBay account disconnected.'
|
|
||||||
} else {
|
|
||||||
ebay.error = 'Disconnect failed. Try again.'
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
ebay.error = 'Could not reach the server. Try again.'
|
|
||||||
} finally {
|
|
||||||
ebay.disconnecting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchEbayStatus()
|
|
||||||
|
|
||||||
// Handle OAuth callback redirect params: ?ebay_connected=1 or ?ebay_error=access_denied
|
|
||||||
const connected = route.query.ebay_connected
|
|
||||||
const oauthError = route.query.ebay_error
|
|
||||||
if (connected) {
|
|
||||||
ebay.success = 'eBay account connected! Trust scores will now use the Trading API.'
|
|
||||||
await fetchEbayStatus()
|
|
||||||
router.replace({ query: { ...route.query, ebay_connected: undefined } })
|
|
||||||
} else if (oauthError) {
|
|
||||||
ebay.error = oauthError === 'access_denied'
|
|
||||||
? 'eBay authorization was cancelled.'
|
|
||||||
: `eBay OAuth error: ${oauthError}`
|
|
||||||
router.replace({ query: { ...route.query, ebay_error: undefined } })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -528,7 +373,7 @@ onMounted(async () => {
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Error / success feedback ---- */
|
/* ---- Error feedback ---- */
|
||||||
.settings-error {
|
.settings-error {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--color-danger, #f85149);
|
color: var(--color-danger, #f85149);
|
||||||
|
|
@ -553,100 +398,6 @@ onMounted(async () => {
|
||||||
border-color: var(--app-primary);
|
border-color: var(--app-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-success {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-success, #3fb950);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- eBay Account section ---- */
|
|
||||||
.ebay-status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-status-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-status-dot--on {
|
|
||||||
background: var(--color-success, #3fb950);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-connected,
|
|
||||||
.ebay-disconnected {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-warn {
|
|
||||||
display: block;
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
color: var(--color-warning, #d29922);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-action-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-btn {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-btn:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-btn--primary {
|
|
||||||
background: var(--app-primary);
|
|
||||||
color: var(--color-text-inverse, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-btn--primary:hover:not(:disabled) { opacity: 0.85; }
|
|
||||||
|
|
||||||
.ebay-btn--danger {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-danger, #f85149);
|
|
||||||
border: 1px solid var(--color-danger, #f85149);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-btn--danger:hover:not(:disabled) {
|
|
||||||
background: color-mix(in srgb, var(--color-danger, #f85149) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-btn--upsell {
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
color: var(--color-text);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ebay-btn--upsell:hover { opacity: 0.85; }
|
|
||||||
|
|
||||||
.ebay-btn:focus-visible {
|
|
||||||
outline: 2px solid var(--app-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-btn-group {
|
.theme-btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue