From 0430454dad7ea749cc2744527560f5927b1b05dc Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 3 Apr 2026 22:06:41 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20eBay=20affiliate=20link=20builder?= =?UTF-8?q?=20(Option=20B=20=E2=80=94=20user-configurable,=20CF=20fallback?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _affiliate_url() helper appends EPN params when EBAY_AFFILIATE_CAMPAIGN_ID set - Clean /itm/ URLs by default (no affiliate tracking without explicit opt-in) - affiliate_active flag in search response drives frontend disclosure - SearchView shows 'Links may include an affiliate code' when active - .env.example documents EBAY_AFFILIATE_CAMPAIGN_ID with EPN registration link - Closes #19 --- .env.example | 7 +++++++ api/main.py | 24 +++++++++++++++++++++++- web/src/stores/search.ts | 4 ++++ web/src/views/SearchView.vue | 4 ++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e83c624..ef7b0cb 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,13 @@ SNIPE_DB=data/snipe.db # HEIMDALL_URL=https://license.circuitforge.tech # 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) ──────────────────────────────────────────────────── # When set, a feedback FAB appears in the UI and routes submissions to Forgejo. # Leave unset to silently hide the button (demo/offline deployments). diff --git a/api/main.py b/api/main.py index e0f7e03..0251269 100644 --- a/api/main.py +++ b/api/main.py @@ -69,6 +69,22 @@ def _ebay_creds() -> tuple[str, str, str]: client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip() return client_id, client_secret, env +def _affiliate_url(url: str) -> str: + """Append EPN affiliate params when EBAY_AFFILIATE_CAMPAIGN_ID is configured. + + If the env var is absent or blank, the original URL is returned unchanged. + Params follow the standard EPN deep-link format; siteid=0 = US. + """ + campaign_id = os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip() + if not campaign_id: + return url + sep = "&" if "?" in url else "?" + return ( + f"{url}{sep}mkcid=1&mkrid=711-53200-19255-0" + f"&siteid=0&campid={campaign_id}&toolid=10001&mkevt=1" + ) + + app = FastAPI(title="Snipe API", version="0.1.0", lifespan=_lifespan) app.include_router(ebay_webhook_router) @@ -395,12 +411,18 @@ def search( and shared_store.get_seller("ebay", listing.seller_platform_id) } + def _serialize_listing(l: object) -> dict: + d = dataclasses.asdict(l) + d["url"] = _affiliate_url(d["url"]) + return d + return { - "listings": [dataclasses.asdict(l) for l in listings], + "listings": [_serialize_listing(l) for l in listings], "trust_scores": trust_map, "sellers": seller_map, "market_price": market_price, "adapter_used": adapter_used, + "affiliate_active": bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip()), } diff --git a/web/src/stores/search.ts b/web/src/stores/search.ts index 1fa4fd3..2269e76 100644 --- a/web/src/stores/search.ts +++ b/web/src/stores/search.ts @@ -120,6 +120,7 @@ export const useSearchStore = defineStore('search', () => { ) const marketPrice = ref(cached?.marketPrice ?? null) const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null) + const affiliateActive = ref(false) const loading = ref(false) const error = ref(null) @@ -164,6 +165,7 @@ export const useSearchStore = defineStore('search', () => { sellers: Record market_price: number | null adapter_used: 'api' | 'scraper' + affiliate_active: boolean } results.value = data.listings ?? [] @@ -171,6 +173,7 @@ export const useSearchStore = defineStore('search', () => { sellers.value = new Map(Object.entries(data.sellers ?? {})) marketPrice.value = data.market_price ?? null adapterUsed.value = data.adapter_used ?? null + affiliateActive.value = data.affiliate_active ?? false saveCache({ query: q, results: results.value, @@ -225,6 +228,7 @@ export const useSearchStore = defineStore('search', () => { sellers, marketPrice, adapterUsed, + affiliateActive, loading, error, search, diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue index aad9236..eb47863 100644 --- a/web/src/views/SearchView.vue +++ b/web/src/views/SearchView.vue @@ -296,6 +296,9 @@ · {{ hiddenCount }} hidden by filters + + · Links may include an affiliate code +

@@ -1029,6 +1032,7 @@ async function onSearch() { } .results-hidden { color: var(--color-warning); } +.affiliate-disclosure { color: var(--color-text-muted, #8b949e); font-size: 0.8em; } .toolbar-actions { display: flex; From 860276420e01115eefd6c300249a41043628709f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 18:31:02 -0700 Subject: [PATCH 2/3] refactor: replace _affiliate_url() with circuitforge-core wrap_url() (cf-core #21) --- api/main.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/api/main.py b/api/main.py index 0251269..fc94c6e 100644 --- a/api/main.py +++ b/api/main.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from fastapi.middleware.cors import CORSMiddleware 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.models import SavedSearch as SavedSearchModel, ScammerEntry from app.platforms import SearchFilters @@ -69,21 +70,6 @@ def _ebay_creds() -> tuple[str, str, str]: client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip() return client_id, client_secret, env -def _affiliate_url(url: str) -> str: - """Append EPN affiliate params when EBAY_AFFILIATE_CAMPAIGN_ID is configured. - - If the env var is absent or blank, the original URL is returned unchanged. - Params follow the standard EPN deep-link format; siteid=0 = US. - """ - campaign_id = os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip() - if not campaign_id: - return url - sep = "&" if "?" in url else "?" - return ( - f"{url}{sep}mkcid=1&mkrid=711-53200-19255-0" - f"&siteid=0&campid={campaign_id}&toolid=10001&mkevt=1" - ) - app = FastAPI(title="Snipe API", version="0.1.0", lifespan=_lifespan) app.include_router(ebay_webhook_router) @@ -413,7 +399,7 @@ def search( def _serialize_listing(l: object) -> dict: d = dataclasses.asdict(l) - d["url"] = _affiliate_url(d["url"]) + d["url"] = _wrap_affiliate_url(d["url"], retailer="ebay") return d return { From 0a93b7386ae2a5145ae19248bad9626dc634799d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 19:15:40 -0700 Subject: [PATCH 3/3] docs: update README to reflect MVP feature set (affiliate links, feedback FAB, vision scheduling) --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bb8bcc1..907b0cd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > *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 @@ -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 - **Category history**: derived from the seller's accumulated listing data (Browse API `categories` field); improves with every search, no extra API calls +### Affiliate link builder + +Listing cards surface eBay affiliate-wrapped URLs. Uses `circuitforge_core.affiliates.wrap_url` — resolution order: user opted out → plain URL; user has BYOK affiliate ID → their ID; CF env var set (`EBAY_AFFILIATE_ID`) → CF's ID; otherwise plain URL. Users can configure their own eBay Partner Network ID or opt out entirely in Settings. + +Disclosure tooltip appears on first encounter per-session and on each wrapped link (per-retailer copy from `get_disclosure_text`). + +### Feedback FAB + +In-app feedback button (bottom-right FAB) opens a modal: title, description, optional screenshot. Posts to the CF feedback endpoint. Status probed on load; FAB hidden if endpoint unreachable. + +### Vision task scheduling + +Photo condition assessment tasks queued through `circuitforge_core.tasks.TaskScheduler` — VRAM-aware slot management shared with any other LLM workloads on the same host. Runs moondream2 locally (free tier) or Claude vision (paid/cloud). Results stored per-listing and update the trust score card. + ### Market price comparison Completed sales fetched via eBay Marketplace Insights API (with Browse API fallback for app tiers that don't have Insights access). Median stored per query hash, used to score `price_vs_market` across all listings in a search.