Merge pull request 'feat: eBay affiliate link builder' (#20) from feature/affiliate-links into main

This commit is contained in:
pyr0ball 2026-04-04 19:16:33 -07:00
commit c5988a059d
5 changed files with 39 additions and 2 deletions

View file

@ -47,6 +47,13 @@ SNIPE_DB=data/snipe.db
# HEIMDALL_URL=https://license.circuitforge.tech # HEIMDALL_URL=https://license.circuitforge.tech
# HEIMDALL_ADMIN_TOKEN= # HEIMDALL_ADMIN_TOKEN=
# ── eBay Affiliate (optional) ─────────────────────────────────────────────────
# Set to your eBay Partner Network (EPN) campaign ID to earn commissions on
# listing click-throughs. Leave blank for clean /itm/ URLs (no tracking).
# Register at https://partnernetwork.ebay.com — self-hosted users can use their
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
# EBAY_AFFILIATE_CAMPAIGN_ID=
# ── 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).

View file

@ -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 search + seller trust scoring MVP complete. 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.
## What it does ## What it does
@ -68,6 +68,20 @@ Scans listing titles for signals the item may have undisclosed damage or problem
- **On-demand**: ↻ button on any listing card triggers `POST /api/enrich` — runs enrichment and re-scores without waiting for a second search - **On-demand**: ↻ button on any listing card triggers `POST /api/enrich` — runs enrichment and re-scores without waiting for a second search
- **Category history**: derived from the seller's accumulated listing data (Browse API `categories` field); improves with every search, no extra API calls - **Category history**: derived from the seller's accumulated listing data (Browse API `categories` field); improves with every search, no extra API calls
### Affiliate link builder
Listing cards surface eBay affiliate-wrapped URLs. Uses `circuitforge_core.affiliates.wrap_url` — resolution order: user opted out → plain URL; user has BYOK affiliate ID → their ID; CF env var set (`EBAY_AFFILIATE_ID`) → CF's ID; otherwise plain URL. Users can configure their own eBay Partner Network ID or opt out entirely in Settings.
Disclosure tooltip appears on first encounter per-session and on each wrapped link (per-retailer copy from `get_disclosure_text`).
### Feedback FAB
In-app feedback button (bottom-right FAB) opens a modal: title, description, optional screenshot. Posts to the CF feedback endpoint. Status probed on load; FAB hidden if endpoint unreachable.
### Vision task scheduling
Photo condition assessment tasks queued through `circuitforge_core.tasks.TaskScheduler` — VRAM-aware slot management shared with any other LLM workloads on the same host. Runs moondream2 locally (free tier) or Claude vision (paid/cloud). Results stored per-listing and update the trust score card.
### Market price comparison ### Market price comparison
Completed sales fetched via eBay Marketplace Insights API (with Browse API fallback for app tiers that don't have Insights access). Median stored per query hash, used to score `price_vs_market` across all listings in a search. Completed sales fetched via eBay Marketplace Insights API (with Browse API fallback for app tiers that don't have Insights access). Median stored per query hash, used to score `price_vs_market` across all listings in a search.

View file

@ -23,6 +23,7 @@ from pydantic import BaseModel
from fastapi.middleware.cors import CORSMiddleware 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 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
@ -69,6 +70,7 @@ def _ebay_creds() -> tuple[str, str, str]:
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip() client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
return client_id, client_secret, env return client_id, client_secret, env
app = FastAPI(title="Snipe API", version="0.1.0", lifespan=_lifespan) app = FastAPI(title="Snipe API", version="0.1.0", lifespan=_lifespan)
app.include_router(ebay_webhook_router) app.include_router(ebay_webhook_router)
@ -395,12 +397,18 @@ def search(
and shared_store.get_seller("ebay", listing.seller_platform_id) and shared_store.get_seller("ebay", listing.seller_platform_id)
} }
def _serialize_listing(l: object) -> dict:
d = dataclasses.asdict(l)
d["url"] = _wrap_affiliate_url(d["url"], retailer="ebay")
return d
return { return {
"listings": [dataclasses.asdict(l) for l in listings], "listings": [_serialize_listing(l) for l in listings],
"trust_scores": trust_map, "trust_scores": trust_map,
"sellers": seller_map, "sellers": seller_map,
"market_price": market_price, "market_price": market_price,
"adapter_used": adapter_used, "adapter_used": adapter_used,
"affiliate_active": bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip()),
} }

View file

@ -120,6 +120,7 @@ export const useSearchStore = defineStore('search', () => {
) )
const marketPrice = ref<number | null>(cached?.marketPrice ?? null) const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null) const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
const affiliateActive = ref<boolean>(false)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -164,6 +165,7 @@ export const useSearchStore = defineStore('search', () => {
sellers: Record<string, Seller> sellers: Record<string, Seller>
market_price: number | null market_price: number | null
adapter_used: 'api' | 'scraper' adapter_used: 'api' | 'scraper'
affiliate_active: boolean
} }
results.value = data.listings ?? [] results.value = data.listings ?? []
@ -171,6 +173,7 @@ export const useSearchStore = defineStore('search', () => {
sellers.value = new Map(Object.entries(data.sellers ?? {})) sellers.value = new Map(Object.entries(data.sellers ?? {}))
marketPrice.value = data.market_price ?? null marketPrice.value = data.market_price ?? null
adapterUsed.value = data.adapter_used ?? null adapterUsed.value = data.adapter_used ?? null
affiliateActive.value = data.affiliate_active ?? false
saveCache({ saveCache({
query: q, query: q,
results: results.value, results: results.value,
@ -225,6 +228,7 @@ export const useSearchStore = defineStore('search', () => {
sellers, sellers,
marketPrice, marketPrice,
adapterUsed, adapterUsed,
affiliateActive,
loading, loading,
error, error,
search, search,

View file

@ -296,6 +296,9 @@
<span v-if="hiddenCount > 0" class="results-hidden"> <span v-if="hiddenCount > 0" class="results-hidden">
· {{ hiddenCount }} hidden by filters · {{ hiddenCount }} hidden by filters
</span> </span>
<span v-if="store.affiliateActive" class="affiliate-disclosure">
· Links may include an affiliate code
</span>
</p> </p>
<div class="toolbar-actions"> <div class="toolbar-actions">
<label for="sort-select" class="sr-only">Sort by</label> <label for="sort-select" class="sr-only">Sort by</label>
@ -1029,6 +1032,7 @@ async function onSearch() {
} }
.results-hidden { color: var(--color-warning); } .results-hidden { color: var(--color-warning); }
.affiliate-disclosure { color: var(--color-text-muted, #8b949e); font-size: 0.8em; }
.toolbar-actions { .toolbar-actions {
display: flex; display: flex;