Compare commits
No commits in common. "main" and "feature/affiliate-links" have entirely different histories.
main
...
feature/af
14 changed files with 143 additions and 538 deletions
22
.env.example
22
.env.example
|
|
@ -54,28 +54,6 @@ SNIPE_DB=data/snipe.db
|
||||||
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
|
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
|
||||||
# EBAY_AFFILIATE_CAMPAIGN_ID=
|
# EBAY_AFFILIATE_CAMPAIGN_ID=
|
||||||
|
|
||||||
# ── LLM inference (vision / photo analysis) ──────────────────────────────────
|
|
||||||
# circuitforge-core LLMRouter auto-detects backends from these env vars
|
|
||||||
# (no llm.yaml required). Backends are tried in this priority order:
|
|
||||||
# 1. ANTHROPIC_API_KEY → Claude API (cloud; requires Paid tier key)
|
|
||||||
# 2. OPENAI_API_KEY → OpenAI-compatible endpoint
|
|
||||||
# 3. OLLAMA_HOST → local Ollama (default: http://localhost:11434)
|
|
||||||
# Leave all unset to disable LLM features (photo analysis won't run).
|
|
||||||
|
|
||||||
# ANTHROPIC_API_KEY=
|
|
||||||
# ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
|
||||||
|
|
||||||
# OPENAI_API_KEY=
|
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
|
||||||
# OPENAI_MODEL=gpt-4o-mini
|
|
||||||
|
|
||||||
# OLLAMA_HOST=http://localhost:11434
|
|
||||||
# OLLAMA_MODEL=llava:7b
|
|
||||||
|
|
||||||
# CF Orchestrator — managed inference for Paid+ cloud users (internal use only).
|
|
||||||
# Self-hosted users leave this unset; it has no effect without a valid allocation token.
|
|
||||||
# CF_ORCH_URL=https://orch.circuitforge.tech
|
|
||||||
|
|
||||||
# ── In-app feedback (beta) ────────────────────────────────────────────────────
|
# ── In-app feedback (beta) ────────────────────────────────────────────────────
|
||||||
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
||||||
# Leave unset to silently hide the button (demo/offline deployments).
|
# Leave unset to silently hide the button (demo/offline deployments).
|
||||||
|
|
|
||||||
44
README.md
44
README.md
|
|
@ -4,50 +4,6 @@
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
## Quick install (self-hosted)
|
|
||||||
|
|
||||||
**Requirements:** Docker with Compose plugin, Git. No API keys needed to get started.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# One-line install — clones to ~/snipe by default
|
|
||||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh)
|
|
||||||
|
|
||||||
# Or clone manually and run the script:
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
|
||||||
bash snipe/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open **http://localhost:8509**.
|
|
||||||
|
|
||||||
### Manual setup (if you prefer)
|
|
||||||
|
|
||||||
Snipe's API image is built from a parent context that includes `circuitforge-core`. Both repos must sit as siblings in the same directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
workspace/
|
|
||||||
├── snipe/ ← this repo
|
|
||||||
└── circuitforge-core/ ← required sibling
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir snipe-workspace && cd snipe-workspace
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
|
|
||||||
cd snipe
|
|
||||||
cp .env.example .env # edit if you have eBay API credentials (optional)
|
|
||||||
./manage.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optional: eBay API credentials
|
|
||||||
|
|
||||||
Snipe works without any credentials using its Playwright scraper fallback. Adding eBay API credentials unlocks faster searches and inline seller account age (no extra scrape needed):
|
|
||||||
|
|
||||||
1. Register at [developer.ebay.com](https://developer.ebay.com/my/keys)
|
|
||||||
2. Copy your Production **App ID** and **Cert ID** into `.env`
|
|
||||||
3. Restart: `./manage.sh restart`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
Snipe has two layers that work together:
|
Snipe has two layers that work together:
|
||||||
|
|
|
||||||
126
api/main.py
126
api/main.py
|
|
@ -11,7 +11,12 @@ from pathlib import Path
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import platform as _platform
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import requests as _requests
|
||||||
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
|
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -19,7 +24,6 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from circuitforge_core.config import load_env
|
from circuitforge_core.config import load_env
|
||||||
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
||||||
from circuitforge_core.api import make_feedback_router as _make_feedback_router
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
||||||
from app.platforms import SearchFilters
|
from app.platforms import SearchFilters
|
||||||
|
|
@ -77,12 +81,6 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
_feedback_router = _make_feedback_router(
|
|
||||||
repo="Circuit-Forge/snipe",
|
|
||||||
product="snipe",
|
|
||||||
)
|
|
||||||
app.include_router(_feedback_router, prefix="/api/feedback")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
|
|
@ -647,3 +645,117 @@ async def import_blocklist(
|
||||||
return {"imported": imported, "errors": errors}
|
return {"imported": imported, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feedback ────────────────────────────────────────────────────────────────
|
||||||
|
# Creates Forgejo issues from in-app beta feedback.
|
||||||
|
# Silently disabled when FORGEJO_API_TOKEN is not set.
|
||||||
|
|
||||||
|
_FEEDBACK_LABEL_COLORS = {
|
||||||
|
"beta-feedback": "#0075ca",
|
||||||
|
"needs-triage": "#e4e669",
|
||||||
|
"bug": "#d73a4a",
|
||||||
|
"feature-request": "#a2eeef",
|
||||||
|
"question": "#d876e3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fb_headers() -> dict:
|
||||||
|
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
|
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_feedback_labels(names: list[str]) -> list[int]:
|
||||||
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
|
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
|
||||||
|
resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
|
||||||
|
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
|
||||||
|
ids: list[int] = []
|
||||||
|
for name in names:
|
||||||
|
if name in existing:
|
||||||
|
ids.append(existing[name])
|
||||||
|
else:
|
||||||
|
r = _requests.post(
|
||||||
|
f"{base}/repos/{repo}/labels",
|
||||||
|
headers=_fb_headers(),
|
||||||
|
json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.ok:
|
||||||
|
ids.append(r.json()["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: Literal["bug", "feature", "other"] = "other"
|
||||||
|
repro: str = ""
|
||||||
|
view: str = "unknown"
|
||||||
|
submitter: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackResponse(BaseModel):
|
||||||
|
issue_number: int
|
||||||
|
issue_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/feedback/status")
|
||||||
|
def feedback_status() -> dict:
|
||||||
|
"""Return whether feedback submission is configured on this instance."""
|
||||||
|
demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
|
||||||
|
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/feedback", response_model=FeedbackResponse)
|
||||||
|
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||||
|
"""File a Forgejo issue from in-app feedback."""
|
||||||
|
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
|
||||||
|
if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
|
||||||
|
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags", "--always"],
|
||||||
|
cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
|
||||||
|
).strip()
|
||||||
|
except Exception:
|
||||||
|
version = "dev"
|
||||||
|
|
||||||
|
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
|
||||||
|
body_lines = [
|
||||||
|
f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
|
||||||
|
"",
|
||||||
|
payload.description,
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if payload.type == "bug" and payload.repro:
|
||||||
|
body_lines += ["### Reproduction Steps", "", payload.repro, ""]
|
||||||
|
body_lines += [
|
||||||
|
"### Context", "",
|
||||||
|
f"- **view:** {payload.view}",
|
||||||
|
f"- **version:** {version}",
|
||||||
|
f"- **platform:** {_platform.platform()}",
|
||||||
|
f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if payload.submitter:
|
||||||
|
body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
|
||||||
|
|
||||||
|
labels = ["beta-feedback", "needs-triage",
|
||||||
|
{"bug": "bug", "feature": "feature-request"}.get(payload.type, "question")]
|
||||||
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
|
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
|
||||||
|
|
||||||
|
label_ids = _ensure_feedback_labels(labels)
|
||||||
|
resp = _requests.post(
|
||||||
|
f"{base}/repos/{repo}/issues",
|
||||||
|
headers=_fb_headers(),
|
||||||
|
json={"title": payload.title, "body": "\n".join(body_lines), "labels": label_ids},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
-- Staging DB: persistent listing tracking across searches.
|
-- Staging DB: persistent listing tracking across searches.
|
||||||
-- Adds temporal metadata to listings so we can detect stale/repriced/recurring items.
|
-- Adds temporal metadata to listings so we can detect stale/repriced/recurring items.
|
||||||
|
|
||||||
ALTER TABLE listings ADD COLUMN first_seen_at TEXT;
|
ALTER TABLE listings ADD COLUMN first_seen_at TEXT;
|
||||||
ALTER TABLE listings ADD COLUMN last_seen_at TEXT;
|
ALTER TABLE listings ADD COLUMN last_seen_at TEXT;
|
||||||
ALTER TABLE listings ADD COLUMN times_seen INTEGER NOT NULL DEFAULT 1;
|
ALTER TABLE listings ADD COLUMN times_seen INTEGER NOT NULL DEFAULT 1;
|
||||||
ALTER TABLE listings ADD COLUMN price_at_first_seen REAL;
|
ALTER TABLE listings ADD COLUMN price_at_first_seen REAL;
|
||||||
|
|
||||||
-- Backfill existing rows so columns are non-null where we have data
|
-- Backfill existing rows so columns are non-null where we have data
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ class TrustScorer:
|
||||||
signal_scores, is_dup, seller,
|
signal_scores, is_dup, seller,
|
||||||
listing_id=listing.id or 0,
|
listing_id=listing.id or 0,
|
||||||
listing_title=listing.title,
|
listing_title=listing.title,
|
||||||
listing_condition=listing.condition,
|
|
||||||
times_seen=listing.times_seen,
|
times_seen=listing.times_seen,
|
||||||
first_seen_at=listing.first_seen_at,
|
first_seen_at=listing.first_seen_at,
|
||||||
price=listing.price,
|
price=listing.price,
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ _SCRATCH_DENT_KEYWORDS = frozenset([
|
||||||
"crack", "cracked", "chip", "chipped",
|
"crack", "cracked", "chip", "chipped",
|
||||||
"damage", "damaged", "cosmetic damage",
|
"damage", "damaged", "cosmetic damage",
|
||||||
"blemish", "wear", "worn", "worn in",
|
"blemish", "wear", "worn", "worn in",
|
||||||
# Parts / condition catch-alls (also matches eBay condition field strings verbatim)
|
# Parts / condition catch-alls
|
||||||
"as is", "for parts", "parts only", "spares or repair", "parts or repair",
|
"as is", "for parts", "parts only", "spares or repair", "parts or repair",
|
||||||
"parts/repair", "parts or not working", "not working",
|
|
||||||
# Evasive redirects — seller hiding damage detail in listing body
|
# Evasive redirects — seller hiding damage detail in listing body
|
||||||
"see description", "read description", "read listing", "see listing",
|
"see description", "read description", "read listing", "see listing",
|
||||||
"see photos for", "see pics for", "see images for",
|
"see photos for", "see pics for", "see images for",
|
||||||
|
|
@ -73,7 +72,6 @@ class Aggregator:
|
||||||
seller: Optional[Seller],
|
seller: Optional[Seller],
|
||||||
listing_id: int = 0,
|
listing_id: int = 0,
|
||||||
listing_title: str = "",
|
listing_title: str = "",
|
||||||
listing_condition: str = "",
|
|
||||||
times_seen: int = 1,
|
times_seen: int = 1,
|
||||||
first_seen_at: Optional[str] = None,
|
first_seen_at: Optional[str] = None,
|
||||||
price: float = 0.0,
|
price: float = 0.0,
|
||||||
|
|
@ -139,9 +137,7 @@ class Aggregator:
|
||||||
)
|
)
|
||||||
if photo_hash_duplicate and not is_established_retailer:
|
if photo_hash_duplicate and not is_established_retailer:
|
||||||
red_flags.append("duplicate_photo")
|
red_flags.append("duplicate_photo")
|
||||||
if (listing_title and _has_damage_keywords(listing_title)) or (
|
if listing_title and _has_damage_keywords(listing_title):
|
||||||
listing_condition and _has_damage_keywords(listing_condition)
|
|
||||||
):
|
|
||||||
red_flags.append("scratch_dent_mentioned")
|
red_flags.append("scratch_dent_mentioned")
|
||||||
|
|
||||||
# Staging DB signals
|
# Staging DB signals
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
# compose.override.yml — dev-only additions (auto-applied by Docker Compose in dev).
|
|
||||||
# Safe to delete on a self-hosted machine — compose.yml is self-contained.
|
|
||||||
#
|
|
||||||
# What this adds over compose.yml:
|
|
||||||
# - Live source mounts so code changes take effect without rebuilding images
|
|
||||||
# - RELOAD=true to enable uvicorn --reload for the API
|
|
||||||
# - NOTE: circuitforge-core is NOT mounted here — use `./manage.sh build` to
|
|
||||||
# pick up cf-core changes. Mounting it as a bind volume would break self-hosted
|
|
||||||
# installs that don't have the sibling directory.
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: snipe/Dockerfile
|
||||||
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
|
- ../circuitforge-core:/app/circuitforge-core
|
||||||
- ./api:/app/snipe/api
|
- ./api:/app/snipe/api
|
||||||
- ./app:/app/snipe/app
|
- ./app:/app/snipe/app
|
||||||
|
- ./data:/app/snipe/data
|
||||||
- ./tests:/app/snipe/tests
|
- ./tests:/app/snipe/tests
|
||||||
environment:
|
environment:
|
||||||
- RELOAD=true
|
- RELOAD=true
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/web/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ./web/src:/app/src # not used at runtime but keeps override valid
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,11 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: snipe/Dockerfile
|
dockerfile: snipe/Dockerfile
|
||||||
# Host networking lets nginx (in the web container) reach the API at
|
ports:
|
||||||
# 172.17.0.1:8510 (the Docker bridge gateway). Required — nginx.conf
|
- "8510:8510"
|
||||||
# is baked into the image and hard-codes that address.
|
|
||||||
network_mode: host
|
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/snipe/data
|
- ./data:/app/snipe/data
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
226
install.sh
226
install.sh
|
|
@ -1,226 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Snipe — self-hosted install script
|
|
||||||
#
|
|
||||||
# Supports two install paths:
|
|
||||||
# Docker (recommended) — everything in containers, no system Python deps required
|
|
||||||
# No-Docker — conda or venv + direct uvicorn, for machines without Docker
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash install.sh # installs to ~/snipe
|
|
||||||
# bash install.sh /opt/snipe # custom install directory
|
|
||||||
# bash install.sh ~/snipe --no-docker # force no-Docker path even if Docker present
|
|
||||||
#
|
|
||||||
# Requirements (Docker path): Docker with Compose plugin, Git
|
|
||||||
# Requirements (no-Docker path): Python 3.11+, Node.js 20+, Git, xvfb (system)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${1:-$HOME/snipe}"
|
|
||||||
FORCE_NO_DOCKER="${2:-}"
|
|
||||||
FORGEJO="https://git.opensourcesolarpunk.com/Circuit-Forge"
|
|
||||||
CONDA_ENV="cf"
|
|
||||||
|
|
||||||
info() { echo " [snipe] $*"; }
|
|
||||||
ok() { echo "✓ $*"; }
|
|
||||||
warn() { echo "! $*"; }
|
|
||||||
fail() { echo "✗ $*" >&2; exit 1; }
|
|
||||||
hr() { echo "────────────────────────────────────────────────────────"; }
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " Snipe — self-hosted installer"
|
|
||||||
echo " Install directory: $INSTALL_DIR"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Detect capabilities ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
HAS_DOCKER=false
|
|
||||||
HAS_CONDA=false
|
|
||||||
HAS_PYTHON=false
|
|
||||||
HAS_NODE=false
|
|
||||||
|
|
||||||
docker compose version >/dev/null 2>&1 && HAS_DOCKER=true
|
|
||||||
conda --version >/dev/null 2>&1 && HAS_CONDA=true
|
|
||||||
python3 --version >/dev/null 2>&1 && HAS_PYTHON=true
|
|
||||||
node --version >/dev/null 2>&1 && HAS_NODE=true
|
|
||||||
command -v git >/dev/null 2>&1 || fail "Git is required. Install with: sudo apt-get install git"
|
|
||||||
|
|
||||||
# Honour --no-docker flag
|
|
||||||
[[ "$FORCE_NO_DOCKER" == "--no-docker" ]] && HAS_DOCKER=false
|
|
||||||
|
|
||||||
if $HAS_DOCKER; then
|
|
||||||
INSTALL_PATH="docker"
|
|
||||||
ok "Docker found — using Docker install path (recommended)"
|
|
||||||
elif $HAS_PYTHON; then
|
|
||||||
INSTALL_PATH="python"
|
|
||||||
warn "Docker not found — using no-Docker path (conda or venv)"
|
|
||||||
else
|
|
||||||
fail "Docker or Python 3.11+ is required. Install Docker: https://docs.docker.com/get-docker/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Clone repos ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# compose.yml and the Dockerfile both use context: .. (parent directory), so
|
|
||||||
# snipe/ and circuitforge-core/ must be siblings inside INSTALL_DIR.
|
|
||||||
SNIPE_DIR="$INSTALL_DIR/snipe"
|
|
||||||
CORE_DIR="$INSTALL_DIR/circuitforge-core"
|
|
||||||
|
|
||||||
if [[ -d "$SNIPE_DIR" ]]; then
|
|
||||||
info "Snipe already cloned — pulling latest..."
|
|
||||||
git -C "$SNIPE_DIR" pull --ff-only
|
|
||||||
else
|
|
||||||
info "Cloning Snipe..."
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
git clone "$FORGEJO/snipe.git" "$SNIPE_DIR"
|
|
||||||
fi
|
|
||||||
ok "Snipe → $SNIPE_DIR"
|
|
||||||
|
|
||||||
if [[ -d "$CORE_DIR" ]]; then
|
|
||||||
info "circuitforge-core already cloned — pulling latest..."
|
|
||||||
git -C "$CORE_DIR" pull --ff-only
|
|
||||||
else
|
|
||||||
info "Cloning circuitforge-core (shared library)..."
|
|
||||||
git clone "$FORGEJO/circuitforge-core.git" "$CORE_DIR"
|
|
||||||
fi
|
|
||||||
ok "circuitforge-core → $CORE_DIR"
|
|
||||||
|
|
||||||
# ── Configure environment ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
ENV_FILE="$SNIPE_DIR/.env"
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
|
||||||
cp "$SNIPE_DIR/.env.example" "$ENV_FILE"
|
|
||||||
# Safe defaults for local installs — no eBay registration, no Heimdall
|
|
||||||
sed -i 's/^EBAY_WEBHOOK_VERIFY_SIGNATURES=true/EBAY_WEBHOOK_VERIFY_SIGNATURES=false/' "$ENV_FILE"
|
|
||||||
ok ".env created from .env.example"
|
|
||||||
echo ""
|
|
||||||
info "Snipe works out of the box with no API keys."
|
|
||||||
info "Add EBAY_APP_ID / EBAY_CERT_ID later for faster searches (optional)."
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
info ".env already exists — skipping (delete it to reset)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$SNIPE_DIR"
|
|
||||||
|
|
||||||
# ── Docker install path ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if [[ "$INSTALL_PATH" == "docker" ]]; then
|
|
||||||
info "Building Docker images (~1 GB download on first run)..."
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
info "Starting Snipe..."
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
ok "Snipe is running!"
|
|
||||||
hr
|
|
||||||
echo " Web UI: http://localhost:8509"
|
|
||||||
echo " API: http://localhost:8510/docs"
|
|
||||||
echo ""
|
|
||||||
echo " Manage: cd $SNIPE_DIR && ./manage.sh {start|stop|restart|logs|test}"
|
|
||||||
hr
|
|
||||||
echo ""
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── No-Docker install path ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# System deps: Xvfb is required for Playwright (Kasada bypass via headed Chromium)
|
|
||||||
if ! command -v Xvfb >/dev/null 2>&1; then
|
|
||||||
info "Installing Xvfb (required for eBay scraper)..."
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
sudo apt-get install -y --no-install-recommends xvfb
|
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
|
||||||
sudo dnf install -y xorg-x11-server-Xvfb
|
|
||||||
elif command -v brew >/dev/null 2>&1; then
|
|
||||||
warn "macOS: Xvfb not available. The scraper fallback may fail."
|
|
||||||
warn "Add eBay API credentials to .env to use the API adapter instead."
|
|
||||||
else
|
|
||||||
warn "Could not install Xvfb automatically. Install it with your package manager."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Python environment setup ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if $HAS_CONDA; then
|
|
||||||
info "Setting up conda environment '$CONDA_ENV'..."
|
|
||||||
if conda env list | grep -q "^$CONDA_ENV "; then
|
|
||||||
info "Conda env '$CONDA_ENV' already exists — updating..."
|
|
||||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$CORE_DIR"
|
|
||||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$SNIPE_DIR"
|
|
||||||
else
|
|
||||||
conda create -n "$CONDA_ENV" python=3.11 -y
|
|
||||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$CORE_DIR"
|
|
||||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$SNIPE_DIR"
|
|
||||||
fi
|
|
||||||
conda run -n "$CONDA_ENV" playwright install chromium
|
|
||||||
conda run -n "$CONDA_ENV" playwright install-deps chromium
|
|
||||||
PYTHON_RUN="conda run -n $CONDA_ENV"
|
|
||||||
ok "Conda environment '$CONDA_ENV' ready"
|
|
||||||
else
|
|
||||||
info "Setting up Python venv at $SNIPE_DIR/.venv ..."
|
|
||||||
python3 -m venv "$SNIPE_DIR/.venv"
|
|
||||||
"$SNIPE_DIR/.venv/bin/pip" install --quiet -e "$CORE_DIR"
|
|
||||||
"$SNIPE_DIR/.venv/bin/pip" install --quiet -e "$SNIPE_DIR"
|
|
||||||
"$SNIPE_DIR/.venv/bin/playwright" install chromium
|
|
||||||
"$SNIPE_DIR/.venv/bin/playwright" install-deps chromium
|
|
||||||
PYTHON_RUN="$SNIPE_DIR/.venv/bin"
|
|
||||||
ok "Python venv ready at $SNIPE_DIR/.venv"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Frontend ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if $HAS_NODE; then
|
|
||||||
info "Building Vue frontend..."
|
|
||||||
cd "$SNIPE_DIR/web"
|
|
||||||
npm ci --prefer-offline --silent
|
|
||||||
npm run build
|
|
||||||
cd "$SNIPE_DIR"
|
|
||||||
ok "Frontend built → web/dist/"
|
|
||||||
else
|
|
||||||
warn "Node.js not found — skipping frontend build."
|
|
||||||
warn "Install Node.js 20+ from https://nodejs.org and re-run install.sh to build the UI."
|
|
||||||
warn "Until then, you can access the API directly at http://localhost:8510/docs"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Write start/stop scripts ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
cat > "$SNIPE_DIR/start-local.sh" << 'STARTSCRIPT'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Start Snipe without Docker (API only — run from the snipe/ directory)
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
if [[ -f .venv/bin/uvicorn ]]; then
|
|
||||||
UVICORN=".venv/bin/uvicorn"
|
|
||||||
elif command -v conda >/dev/null 2>&1 && conda env list | grep -q "^cf "; then
|
|
||||||
UVICORN="conda run -n cf uvicorn"
|
|
||||||
else
|
|
||||||
echo "No Python env found. Run install.sh first." >&2; exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p data
|
|
||||||
echo "Starting Snipe API on http://localhost:8510 ..."
|
|
||||||
$UVICORN api.main:app --host 0.0.0.0 --port 8510 "${@}"
|
|
||||||
STARTSCRIPT
|
|
||||||
chmod +x "$SNIPE_DIR/start-local.sh"
|
|
||||||
|
|
||||||
# Frontend serving (if built)
|
|
||||||
cat > "$SNIPE_DIR/serve-ui.sh" << 'UISCRIPT'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Serve the pre-built Vue frontend on port 8509 (dev only — use nginx for production)
|
|
||||||
cd "$(dirname "$0")/web/dist"
|
|
||||||
python3 -m http.server 8509
|
|
||||||
UISCRIPT
|
|
||||||
chmod +x "$SNIPE_DIR/serve-ui.sh"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
ok "Snipe installed (no-Docker mode)"
|
|
||||||
hr
|
|
||||||
echo " Start API: cd $SNIPE_DIR && ./start-local.sh"
|
|
||||||
echo " Serve UI: cd $SNIPE_DIR && ./serve-ui.sh (separate terminal)"
|
|
||||||
echo " API docs: http://localhost:8510/docs"
|
|
||||||
echo " Web UI: http://localhost:8509 (after ./serve-ui.sh)"
|
|
||||||
echo ""
|
|
||||||
echo " For production, point nginx at web/dist/ and proxy /api/ to localhost:8510"
|
|
||||||
hr
|
|
||||||
echo ""
|
|
||||||
|
|
@ -78,7 +78,7 @@ case "$cmd" in
|
||||||
test)
|
test)
|
||||||
echo "Running test suite..."
|
echo "Running test suite..."
|
||||||
docker compose -f "$COMPOSE_FILE" exec api \
|
docker compose -f "$COMPOSE_FILE" exec api \
|
||||||
python -m pytest /app/snipe/tests/ -v "${@}"
|
conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
# ── Cloud commands ────────────────────────────────────────────────────────
|
# ── Cloud commands ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ version = "0.1.0"
|
||||||
description = "Auction listing monitor and trust scorer"
|
description = "Auction listing monitor and trust scorer"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"circuitforge-core>=0.8.0",
|
"circuitforge-core",
|
||||||
"streamlit>=1.32",
|
"streamlit>=1.32",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
"imagehash>=4.3",
|
"imagehash>=4.3",
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
"""Tests for the shared feedback router (circuitforge-core) mounted in snipe."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from circuitforge_core.api.feedback import make_feedback_router
|
|
||||||
|
|
||||||
|
|
||||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
|
||||||
app = FastAPI()
|
|
||||||
router = make_feedback_router(
|
|
||||||
repo="Circuit-Forge/snipe",
|
|
||||||
product="snipe",
|
|
||||||
demo_mode_fn=demo_mode_fn,
|
|
||||||
)
|
|
||||||
app.include_router(router, prefix="/api/feedback")
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /api/feedback/status ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_status_disabled_when_no_token(monkeypatch):
|
|
||||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
|
||||||
monkeypatch.delenv("DEMO_MODE", raising=False)
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
res = client.get("/api/feedback/status")
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == {"enabled": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_enabled_when_token_set(monkeypatch):
|
|
||||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
res = client.get("/api/feedback/status")
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == {"enabled": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_disabled_in_demo_mode(monkeypatch):
|
|
||||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
|
||||||
demo = True
|
|
||||||
client = _make_client(demo_mode_fn=lambda: demo)
|
|
||||||
res = client.get("/api/feedback/status")
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == {"enabled": False}
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /api/feedback ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_submit_returns_503_when_no_token(monkeypatch):
|
|
||||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
res = client.post("/api/feedback", json={
|
|
||||||
"title": "Test", "description": "desc", "type": "bug",
|
|
||||||
})
|
|
||||||
assert res.status_code == 503
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_returns_403_in_demo_mode(monkeypatch):
|
|
||||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
|
||||||
demo = True
|
|
||||||
client = _make_client(demo_mode_fn=lambda: demo)
|
|
||||||
res = client.post("/api/feedback", json={
|
|
||||||
"title": "Test", "description": "desc", "type": "bug",
|
|
||||||
})
|
|
||||||
assert res.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_creates_issue(monkeypatch):
|
|
||||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
label_response = MagicMock()
|
|
||||||
label_response.ok = True
|
|
||||||
label_response.json.return_value = [
|
|
||||||
{"id": 1, "name": "beta-feedback"},
|
|
||||||
{"id": 2, "name": "needs-triage"},
|
|
||||||
{"id": 3, "name": "bug"},
|
|
||||||
]
|
|
||||||
|
|
||||||
issue_response = MagicMock()
|
|
||||||
issue_response.ok = True
|
|
||||||
issue_response.json.return_value = {
|
|
||||||
"number": 7,
|
|
||||||
"html_url": "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7",
|
|
||||||
}
|
|
||||||
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
|
|
||||||
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
|
|
||||||
patch("circuitforge_core.api.feedback.requests.post", return_value=issue_response):
|
|
||||||
res = client.post("/api/feedback", json={
|
|
||||||
"title": "Listing scores wrong",
|
|
||||||
"description": "Trust score shows 0 when seller has 1000 feedback",
|
|
||||||
"type": "bug",
|
|
||||||
"repro": "1. Search for anything\n2. Check trust score",
|
|
||||||
"tab": "search",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["issue_number"] == 7
|
|
||||||
assert data["issue_url"] == "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7"
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_returns_502_on_forgejo_error(monkeypatch):
|
|
||||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
label_response = MagicMock()
|
|
||||||
label_response.ok = True
|
|
||||||
label_response.json.return_value = [
|
|
||||||
{"id": 1, "name": "beta-feedback"},
|
|
||||||
{"id": 2, "name": "needs-triage"},
|
|
||||||
{"id": 3, "name": "question"},
|
|
||||||
]
|
|
||||||
|
|
||||||
bad_response = MagicMock()
|
|
||||||
bad_response.ok = False
|
|
||||||
bad_response.text = "internal server error"
|
|
||||||
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
|
|
||||||
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
|
|
||||||
patch("circuitforge_core.api.feedback.requests.post", return_value=bad_response):
|
|
||||||
res = client.post("/api/feedback", json={
|
|
||||||
"title": "Oops", "description": "desc", "type": "other",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert res.status_code == 502
|
|
||||||
|
|
@ -80,45 +80,6 @@ def test_suspicious_price_flagged_when_price_genuinely_low():
|
||||||
assert "suspicious_price" in result.red_flags_json
|
assert "suspicious_price" in result.red_flags_json
|
||||||
|
|
||||||
|
|
||||||
def test_scratch_dent_flagged_from_title_slash_variant():
|
|
||||||
"""Title containing 'parts/repair' (slash variant, no 'or') must trigger scratch_dent_mentioned."""
|
|
||||||
agg = Aggregator()
|
|
||||||
scores = {k: 15 for k in ["account_age", "feedback_count",
|
|
||||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
|
||||||
result = agg.aggregate(
|
|
||||||
scores, photo_hash_duplicate=False, seller=None,
|
|
||||||
listing_title="Generic Widget XL - Parts/Repair",
|
|
||||||
)
|
|
||||||
assert "scratch_dent_mentioned" in result.red_flags_json
|
|
||||||
|
|
||||||
|
|
||||||
def test_scratch_dent_flagged_from_condition_field():
|
|
||||||
"""eBay formal condition 'for parts or not working' must trigger scratch_dent_mentioned
|
|
||||||
even when the listing title contains no damage keywords."""
|
|
||||||
agg = Aggregator()
|
|
||||||
scores = {k: 15 for k in ["account_age", "feedback_count",
|
|
||||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
|
||||||
result = agg.aggregate(
|
|
||||||
scores, photo_hash_duplicate=False, seller=None,
|
|
||||||
listing_title="Generic Widget XL",
|
|
||||||
listing_condition="for parts or not working",
|
|
||||||
)
|
|
||||||
assert "scratch_dent_mentioned" in result.red_flags_json
|
|
||||||
|
|
||||||
|
|
||||||
def test_scratch_dent_not_flagged_for_clean_listing():
|
|
||||||
"""Clean title + 'New' condition must NOT trigger scratch_dent_mentioned."""
|
|
||||||
agg = Aggregator()
|
|
||||||
scores = {k: 15 for k in ["account_age", "feedback_count",
|
|
||||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
|
||||||
result = agg.aggregate(
|
|
||||||
scores, photo_hash_duplicate=False, seller=None,
|
|
||||||
listing_title="Generic Widget XL",
|
|
||||||
listing_condition="new",
|
|
||||||
)
|
|
||||||
assert "scratch_dent_mentioned" not in result.red_flags_json
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_account_not_flagged_when_age_absent():
|
def test_new_account_not_flagged_when_age_absent():
|
||||||
"""account_age_days=None (scraper tier) must NOT trigger new_account or account_under_30_days."""
|
"""account_age_days=None (scraper tier) must NOT trigger new_account or account_under_30_days."""
|
||||||
agg = Aggregator()
|
agg = Aggregator()
|
||||||
|
|
|
||||||
|
|
@ -91,17 +91,6 @@
|
||||||
aria-label="Search filters"
|
aria-label="Search filters"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- Clear all filters — only shown when at least one filter is active -->
|
|
||||||
<button
|
|
||||||
v-if="activeFilterCount > 0"
|
|
||||||
type="button"
|
|
||||||
class="filter-clear-btn"
|
|
||||||
@click="resetFilters"
|
|
||||||
aria-label="Clear all filters"
|
|
||||||
>
|
|
||||||
✕ Clear filters ({{ activeFilterCount }})
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- ── 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. -->
|
||||||
<h2 class="filter-section-heading filter-section-heading--search">
|
<h2 class="filter-section-heading filter-section-heading--search">
|
||||||
|
|
@ -416,7 +405,7 @@ onMounted(() => {
|
||||||
|
|
||||||
// ── Filters ──────────────────────────────────────────────────────────────────
|
// ── Filters ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_FILTERS: SearchFilters = {
|
const filters = reactive<SearchFilters>({
|
||||||
minTrustScore: 0,
|
minTrustScore: 0,
|
||||||
minPrice: undefined,
|
minPrice: undefined,
|
||||||
maxPrice: undefined,
|
maxPrice: undefined,
|
||||||
|
|
@ -435,13 +424,7 @@ const DEFAULT_FILTERS: SearchFilters = {
|
||||||
mustExclude: '',
|
mustExclude: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
||||||
}
|
})
|
||||||
|
|
||||||
const filters = reactive<SearchFilters>({ ...DEFAULT_FILTERS })
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
Object.assign(filters, DEFAULT_FILTERS)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse comma-separated keyword strings into trimmed, lowercase, non-empty term arrays
|
// Parse comma-separated keyword strings into trimmed, lowercase, non-empty term arrays
|
||||||
const parsedMustInclude = computed(() =>
|
const parsedMustInclude = computed(() =>
|
||||||
|
|
@ -784,27 +767,6 @@ async function onSearch() {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clear all filters button */
|
|
||||||
.filter-clear-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
background: color-mix(in srgb, var(--color-red, #ef4444) 12%, transparent);
|
|
||||||
color: var(--color-red, #ef4444);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-red, #ef4444) 30%, transparent);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.filter-clear-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--color-red, #ef4444) 22%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section headings that separate eBay Search params from local filters */
|
/* Section headings that separate eBay Search params from local filters */
|
||||||
.filter-section-heading {
|
.filter-section-heading {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue