A Product Research Agent That Reads Ratings Honestly
Product listings come with ratings, review counts, and badges. Most shopping agents misuse all three.


- The endpoint
- Parsing defensively
- Confidence-adjusted ranking
- Filtering before the model
- The agent layer
- Reading the listing page
A 5.0-star product with three reviews is not better than a 4.6 with four thousand, but sort by rating and that’s exactly what you’ll get on top. Shopping agents inherit this bug constantly, because “recommend the highest rated” is the obvious instruction and it’s wrong.
The endpoint
Serply’s product search returns listings from Amazon, using the same path-embedded query convention as the other search endpoints:
import os
import requests
from urllib.parse import quote_plus
API_KEY = os.environ["SERPLY_API_KEY"]
def product_search(query: str) -> list[dict]:
resp = requests.get(
f"https://api.serply.io/v1/product/search/q={quote_plus(query)}",
headers={"X-Api-Key": API_KEY, "X-Proxy-Location": "US"},
timeout=30,
)
resp.raise_for_status()
return resp.json().get("products", [])
products = product_search("espresso machine under 500")
Each product carries title, link, asin, price, rating_stars, review_count, img_url, real_position, extras, and the boolean flags bestseller, prime, and is_sponsor. The response also includes an ads array, a ts timing value, and device_type.
Two field-level gotchas. rating_stars comes back as a string, not a float — it’s a scraped display value. And is_sponsor marks paid placements, which you almost certainly want to exclude from a “best product” recommendation and definitely want to disclose if you don’t.
Parsing defensively
import re
def parse_rating(value) -> float | None:
if value is None:
return None
m = re.search(r"(\d+(?:\.\d+)?)", str(value))
return float(m.group(1)) if m else None
def parse_price(value) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
m = re.search(r"(\d[\d,]*(?:\.\d{1,2})?)", str(value))
return float(m.group(1).replace(",", "")) if m else None
def normalize(p: dict) -> dict:
return {
"title": p.get("title", ""),
"link": p.get("link", ""),
"asin": p.get("asin"),
"price": parse_price(p.get("price")),
"rating": parse_rating(p.get("rating_stars")),
"reviews": p.get("review_count") or 0,
"prime": bool(p.get("prime")),
"bestseller": bool(p.get("bestseller")),
"sponsored": bool(p.get("is_sponsor")),
"position": p.get("real_position"),
}
Returning None rather than a default for a missing rating matters downstream. A product with no rating is not a zero-star product, and coercing it to 0.0 will bury perfectly good new listings.
Confidence-adjusted ranking
The standard fix is a Bayesian average: pull each product’s rating toward the category mean, weighted by how many reviews back it up.
def bayesian_rank(items: list[dict], prior_weight: int = 50) -> list[dict]:
rated = [i for i in items if i["rating"] is not None and i["reviews"] > 0]
if not rated:
return items
total_reviews = sum(i["reviews"] for i in rated)
prior_mean = sum(i["rating"] * i["reviews"] for i in rated) / total_reviews
for item in items:
r, n = item["rating"], item["reviews"]
if r is None or n == 0:
item["score"] = None
continue
item["score"] = (prior_weight * prior_mean + n * r) / (prior_weight + n)
return sorted(items, key=lambda i: (i["score"] is None, -(i["score"] or 0)))
With prior_weight=50, a 5.0 backed by three reviews scores around the category mean, while a 4.6 backed by four thousand scores essentially 4.6. That’s the ordering a careful human would produce.
Tune prior_weight to the category. For something with tens of thousands of reviews per listing, 50 is too weak to matter; for a niche product where 20 reviews is a lot, it’s too aggressive.
Filtering before the model
def shortlist(products: list[dict], max_price: float | None = None,
min_reviews: int = 20, include_sponsored: bool = False) -> list[dict]:
items = [normalize(p) for p in products]
out = []
for i in items:
if not include_sponsored and i["sponsored"]:
continue
if i["reviews"] < min_reviews:
continue
if max_price is not None and i["price"] is not None and i["price"] > max_price:
continue
out.append(i)
return bayesian_rank(out)
Note the price filter only applies when a price was actually parsed. Dropping listings with an unparseable price silently removes real options — better to keep them and let the model see price: None than to pretend they don’t exist.
The agent layer
Everything above is deterministic and should stay that way. What the model is genuinely good at is reading the messy parts — titles, extras, and the actual product page:
import json
from anthropic import Anthropic
client = Anthropic()
SYSTEM = """You recommend products from a pre-filtered, pre-ranked shortlist.
Rules:
- The list is already ranked by confidence-adjusted rating. Respect that ordering
unless you have a specific reason from the title or details to deviate.
- Never recommend a product whose title doesn't match what the user asked for.
Listings frequently include accessories and replacement parts.
- If a spec the user cares about isn't in the data, say it's unverified rather
than assuming.
- Report the raw rating and review count, not the internal score.
Return JSON: {"pick": {...}, "runner_up": {...}, "why": str, "caveats": [str]}"""
def recommend(request: str, items: list[dict]) -> dict:
msg = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1500,
system=SYSTEM,
messages=[{"role": "user", "content":
f"Request: {request}\n\nShortlist:\n{json.dumps(items[:12], indent=2)}"}],
)
return json.loads(msg.content[0].text)
The accessory rule catches the single most common shopping-agent embarrassment: a search for “espresso machine” returns a portafilter basket at position four, and an agent that only reads the ranking recommends it.
Reading the listing page
For a final pick, the listing page has detail the search result doesn’t:
def read_listing(url: str) -> str | None:
try:
resp = requests.post(
"https://api.serply.io/v1/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=60,
)
resp.raise_for_status()
return resp.text[:12000]
except requests.RequestException:
return None
Markdown mode returns the text directly in the response body, so .text is correct. Do this for the top two or three candidates only — it’s the expensive call in this pipeline, and the shortlist has already done most of the work.