feat: multi-platform scaffolding — phase 1 (eBay-only, wire complete)

Backend:
- app/platforms/__init__.py: add SUPPORTED_PLATFORMS frozenset (single
  source of truth for platform validation); add must_include_mode and
  adapter fields to SearchFilters dataclass
- api/main.py: add platform: str = Query("ebay") to both /api/search
  and /api/search/async; validate against SUPPORTED_PLATFORMS (422 on
  unknown platform); thread platform into structured log lines; document
  Phase 2 registry extension point in _make_adapter

Frontend:
- SearchView.vue: platform tab strip (eBay active, Mercari + Poshmark
  disabled with "soon" badge) above search bar; eBay-specific controls
  (category select, data source, pages, keywords) hidden when platform
  !== 'ebay'; platform passed to SearchProgress
- search.ts: platform?: string added to SearchFilters; included in
  async search params when non-eBay
- SearchProgress.vue: platform prop + PLATFORM_LABELS map; status line
  reads "Searching eBay for…" / "Searching Mercari for…" dynamically
This commit is contained in:
pyr0ball 2026-05-02 20:09:36 -07:00
parent b993f6f4a9
commit f48f8ef80f
5 changed files with 133 additions and 9 deletions

View file

@ -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, Request, Response, UploadFile from fastapi import Depends, FastAPI, File, HTTPException, Query, 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 SearchFilters from app.platforms import SUPPORTED_PLATFORMS, 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
@ -674,6 +674,11 @@ def _make_adapter(shared_store: Store, force: str = "auto"):
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.
# Platform registry — add new adapters here as platforms are implemented.
# _make_adapter() currently handles eBay only. Phase 2 will add:
# "mercari": MercariAdapter
# "poshmark": PoshmarkAdapter
""" """
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)
@ -713,8 +718,15 @@ 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)
@ -909,8 +921,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 auth=%s tier=%s adapter=%s pages=%d queries=%d listings=%d q=%r", "search platform=%s auth=%s tier=%s adapter=%s pages=%d queries=%d listings=%d q=%r",
_auth_label(session.user_id), session.tier, adapter_used, platform, _auth_label(session.user_id), session.tier, adapter_used,
pages, len(ebay_queries), len(listings), q, pages, len(ebay_queries), len(listings), q,
) )
@ -1073,6 +1085,7 @@ 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.
@ -1088,6 +1101,12 @@ 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:
@ -1285,8 +1304,8 @@ def search_async(
comps_future.result() comps_future.result()
log.info( log.info(
"async_search auth=%s tier=%s adapter=%s pages=%d listings=%d q=%r", "async_search platform=%s auth=%s tier=%s adapter=%s pages=%d listings=%d q=%r",
_auth_label(_user_id), _tier, adapter_used, pages, len(listings), q_norm, platform, _auth_label(_user_id), _tier, adapter_used, pages, len(listings), q_norm,
) )
shared_store = Store(_shared_db) shared_store = Store(_shared_db)

View file

@ -7,6 +7,10 @@ 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"})
@dataclass @dataclass
class SearchFilters: class SearchFilters:
@ -18,6 +22,8 @@ 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):

View file

@ -7,7 +7,7 @@
<!-- Status line --> <!-- Status line -->
<p class="progress-label"> <p class="progress-label">
Searching eBay for <strong>{{ query }}</strong> Searching <strong>{{ platformLabel }}</strong> for <strong>{{ query }}</strong>
</p> </p>
<!-- Skeleton listing cards --> <!-- Skeleton listing cards -->
@ -28,7 +28,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ query: string }>() 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> </script>
<style scoped> <style scoped>

View file

@ -59,6 +59,11 @@ 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 {
@ -93,6 +98,7 @@ 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 ─────────────────────────────────────────────────────────────
@ -173,6 +179,7 @@ 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.

View file

@ -2,8 +2,29 @@
<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"
@ -20,6 +41,7 @@
</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"
@ -116,6 +138,7 @@
<!-- 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>
@ -216,6 +239,7 @@
<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. -->
@ -356,7 +380,7 @@
</div> </div>
<!-- Loading (scraping in progress, no results yet) --> <!-- Loading (scraping in progress, no results yet) -->
<SearchProgress v-else-if="store.loading && !store.results.length" :query="store.query" /> <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">
@ -636,6 +660,7 @@ 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 })
@ -671,6 +696,12 @@ 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: false },
{ 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' },
@ -1795,4 +1826,53 @@ 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>