A Deal-Hunting Agent That Knows What a Good Price Looks Like

Finding a cheap listing is easy. Knowing whether it's a bargain or a broken unit requires comparing it to the market.

Profile picture of Serply
Serply
Marketplace listings plotted against a computed market price

“Find me the cheapest one” is the wrong instruction for a shopping agent, because the cheapest listing for anything desirable is usually damaged, counterfeit, or a photo of the box. What you actually want is the best price relative to the market, with the outliers flagged rather than recommended.

That requires computing the market first.

The endpoint

import os
import requests
from urllib.parse import quote_plus

API_KEY = os.environ["SERPLY_API_KEY"]


def ebay_search(query: str, location: str = "US") -> dict:
    resp = requests.get(
        f"https://api.serply.io/v1/ebay/search/q={quote_plus(query)}",
        headers={"X-Api-Key": API_KEY, "X-Proxy-Location": location},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()


data = ebay_search("sony wh-1000xm4")
for r in data.get("results", []):
    m = r.get("metadata", {})
    print(r["title"], "|", m.get("price"), "|", m.get("condition"))

Listings come back under results, each with title, link, position, result_type, and a metadata object carrying price, was_price, condition, image, seller, seller_feedback, and attributes. The response also includes total, query, ts, device_region, and device_type.

seller_feedback and condition are the two fields that turn this from a price list into something you can reason about.

Parsing the messy parts

Prices arrive as display strings, and not always as a single number:

import re


def parse_price(value) -> float | None:
    if value is None:
        return None
    if isinstance(value, (int, float)):
        return float(value)
    text = str(value)
    # Ranges like "$45.00 to $89.99" — take the low end
    matches = re.findall(r"(\d[\d,]*(?:\.\d{1,2})?)", text)
    if not matches:
        return None
    return float(matches[0].replace(",", ""))


def parse_feedback(value) -> float | None:
    """Seller feedback often arrives as '98.7%' or '(1,234) 99.2%'."""
    if value is None:
        return None
    m = re.search(r"(\d{1,3}(?:\.\d+)?)\s*%", str(value))
    return float(m.group(1)) if m else None


CONDITION_MAP = {
    "new": "new", "brand new": "new", "new with tags": "new",
    "new other": "open_box", "open box": "open_box",
    "certified refurbished": "refurbished", "seller refurbished": "refurbished",
    "excellent - refurbished": "refurbished", "refurbished": "refurbished",
    "used": "used", "pre-owned": "used", "very good": "used", "good": "used",
    "for parts or not working": "parts", "parts only": "parts",
}


def normalize_condition(value) -> str:
    key = str(value or "").strip().lower()
    return CONDITION_MAP.get(key, "unknown")

The condition mapping is where most of the value is. eBay’s condition strings vary by category and seller, and “For parts or not working” listings are precisely the ones that will top a cheapest-first sort.

Normalising a listing

def normalize(r: dict) -> dict:
    m = r.get("metadata", {})
    price = parse_price(m.get("price"))
    was = parse_price(m.get("was_price"))
    return {
        "title": r.get("title", ""),
        "url": r.get("link", ""),
        "price": price,
        "was_price": was,
        "discount": round(1 - price / was, 3) if price and was and was > price else None,
        "condition": normalize_condition(m.get("condition")),
        "seller": m.get("seller"),
        "feedback": parse_feedback(m.get("seller_feedback")),
        "position": r.get("position"),
    }

Computing the market baseline

Median, not mean, and per condition — a used median and a new median are different markets:

import statistics
from collections import defaultdict


def baselines(items: list[dict]) -> dict[str, dict]:
    by_condition = defaultdict(list)
    for i in items:
        if i["price"] is not None and i["condition"] != "parts":
            by_condition[i["condition"]].append(i["price"])

    out = {}
    for condition, prices in by_condition.items():
        if len(prices) < 4:
            continue
        prices.sort()
        out[condition] = {
            "median": statistics.median(prices),
            "p25": prices[len(prices) // 4],
            "p75": prices[(3 * len(prices)) // 4],
            "n": len(prices),
        }
    return out

Requiring at least four listings before computing a baseline is the guard that stops a two-listing category from producing a “median” that’s really just one number. Without it you’ll flag deals against noise.

Classifying each listing

def classify(item: dict, base: dict) -> dict:
    ref = base.get(item["condition"])
    if not ref or item["price"] is None:
        return {**item, "verdict": "unknown",
                "reason": "insufficient comparable listings"}

    ratio = item["price"] / ref["median"]

    if item["condition"] == "parts":
        verdict, reason = "avoid", "listed for parts or not working"
    elif ratio < 0.5:
        verdict, reason = "suspicious", (
            f"{1 - ratio:.0%} below the {item['condition']} median — "
            "verify authenticity, completeness, and seller history"
        )
    elif ratio < 0.8:
        verdict, reason = "deal", f"{1 - ratio:.0%} below median for this condition"
    elif ratio <= 1.15:
        verdict, reason = "market", "priced in line with comparable listings"
    else:
        verdict, reason = "overpriced", f"{ratio - 1:.0%} above median"

    if item["feedback"] is not None and item["feedback"] < 95 and verdict == "deal":
        verdict = "deal_with_risk"
        reason += f"; seller feedback {item['feedback']}%"

    return {**item, "verdict": verdict, "reason": reason,
            "vs_median": round(ratio, 2)}

The suspicious band is the whole point. A 60%-below-median listing is not a better deal than a 25%-below one; it’s a different kind of thing, and collapsing them into “cheapest” is how agents recommend counterfeits.

The feedback downgrade is a separate axis deliberately — a good price from a 91% seller is a real trade-off to surface, not a listing to hide.

Putting it together

def hunt(query: str, location: str = "US") -> dict:
    raw = ebay_search(query, location)
    items = [normalize(r) for r in raw.get("results", [])]
    base = baselines(items)
    classified = [classify(i, base) for i in items]

    order = {"deal": 0, "deal_with_risk": 1, "market": 2,
             "suspicious": 3, "overpriced": 4, "unknown": 5, "avoid": 6}
    classified.sort(key=lambda i: (order[i["verdict"]], i["vs_median"] or 99))

    return {"query": query, "baselines": base, "listings": classified}

The model’s actual job

The arithmetic above is deterministic and should stay in code. What a model adds is reading titles, which are where the real traps live:

import json
from anthropic import Anthropic

client = Anthropic()

SYSTEM = """You review pre-classified marketplace listings for a buyer.

The price analysis is already done — do not recompute it or contradict the
verdicts. Your job is to read the TITLES for problems the price data cannot show:

- Accessories or parts sold as the product (case, cable, replacement part)
- Compatible/aftermarket items rather than the genuine article
- Lots and bundles where the per-unit price differs from the listing price
- Wrong model, wrong generation, wrong region variant
- Titles that contradict their stated condition

Recommend at most 3 listings. For each, give the price, condition, and the
specific reason. If every listing has a problem, say so and recommend none.
Never invent shipping costs, return policies, or delivery estimates."""


def review(query: str) -> str:
    data = hunt(query)
    msg = client.messages.create(
        model="claude-sonnet-4-5", max_tokens=2000, system=SYSTEM,
        messages=[{"role": "user", "content": json.dumps(
            {"query": query, "baselines": data["baselines"],
             "listings": data["listings"][:20]}, indent=2)}],
    )
    return msg.content[0].text

“Recommend none” has to be an allowed outcome. A search that returns twenty accessory listings and no actual product should produce that answer, and an agent that must always pick something will pick a phone case.

One note on scope: this uses the documented query parameter only. Everything above — condition handling, price bands, seller risk — is computed client-side from the returned metadata, which means it works regardless of what filtering the upstream marketplace exposes.