# Comps

> Research pricing comparables for a secondhand item using live eBay sold listings and active Facebook Marketplace listings. Use whenever the user wants to know what a used item is worth, find resale comps, check sold prices, price an item for sale on Facebook Marketplace or eBay, or build a pricing recommendation for resale. Trigger on phrases like "what's this worth", "price comps", "sold for", "resale value", "list price for". Always pull live data via browser automation — do not rely on training data for current market prices.

- Skill: `hansohn/comps` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hansohn/comps`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hansohn/comps/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Research & Search
- Author: hansohn (https://skillmd.com/u/hansohn)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hansohn/comps

---


# Marketplace Comps

Pull live pricing comparables for a secondhand item from eBay (sold/completed listings) and Facebook Marketplace (active listings), then synthesize a recommended list price.

## When to use

Trigger automatically when:
- The user names a specific used item and asks what it's worth, what it sold for, or how to price it
- The `/marketplace:listing` orchestrator delegates pricing research
- The user is preparing a Facebook Marketplace or eBay listing

Do not use for new-item retail pricing (use a regular web search) or for pricing services/labor.

## Required inputs

- **Item name** — Make + model, with model number if available (e.g., "Strider 12 Sport Balance Bike ST-S4RD")
- **Condition** — New, Used (Like New / Good / Fair), Open Box
- **Location** — User's city + state + zip, used for FB Marketplace search context (when not skipped) and to phrase the local-pickup pricing premium in the recommendation paragraph. The orchestrator resolves this from CLI flag / user memory / interactive prompt — this skill never hardcodes a default. If the orchestrator did not pass a location and one is genuinely needed (FB research is enabled), ask the user before proceeding.

If any are missing and not derivable from context (CSV row, prior conversation, item details file), ask before continuing — surface the gap with concrete options rather than guessing (see `${CLAUDE_PLUGIN_ROOT}/references/handling-ambiguity.md`).

## Process

### 1. eBay sold listings (primary signal)

**First, confirm a browser MCP is available.** This step's primary path uses Playwright browser tools (`browser_navigate`, `browser_evaluate`, etc.). If no such tool exists — the Playwright MCP isn't installed or configured — **don't error out and don't fabricate prices.** Skip straight to the `WebSearch`/`WebFetch` path (the same fallback described under failure mode 1) to gather sold-price data, and note in the output that pricing came from search results rather than a live DOM scrape, so the comp set is thinner and less precise. If neither browser nor web tools are available, emit the honest "no comparable data found" result (see Notes) — never invent prices. Everything below assumes the browser MCP exists.

**Warm up the session first (do this before the sold-listings URL).** A cold, cookieless hit straight to a deep search URL is what most reliably triggers eBay's "Pardon Our Interruption" challenge — especially when the browser MCP runs isolated/headless (a fresh, cold context every run). Navigate to the homepage first to establish a normal session, then go to the search:

1. `browser_navigate` to `https://www.ebay.com` and wait ~2s for it to settle.
2. *Then* `browser_navigate` to the sold-listings URL below.

This extra navigation is cheap and may reduce challenges, but **it is not reliable on its own** — a smoke test still hit the interstitial right after warming up. So treat warm-up as a minor preventive nicety; the real defense is the wait-and-retry recovery in failure mode 1 below.

Use the playwright browser MCP to navigate to:

```
https://www.ebay.com/sch/i.html?_nkw={URL_ENCODED_QUERY}&LH_Complete=1&LH_Sold=1&_sop=13
```

- `LH_Complete=1&LH_Sold=1` filter to completed + sold listings only
- `_sop=13` sorts by ended-most-recently
- Use the item name as the query; include model number if it narrows results

**After navigation, wait for sold listings to render** with `browser_wait_for` looking for text like `"Sold"` or a sold-listings indicator. This avoids extracting an empty/loading page. If the wait times out after ~5s, proceed anyway — the page may have loaded without the indicator.

**Extract in a single call:**

```js
() => document.body.innerText.substring(0, 25000)
```

One `browser_evaluate` call returns up to 25KB of page text — enough to capture ~30+ sold listings (which appear at the top of the page). Extract in a single call, not chunked slices. A `.s-item` DOM-selector approach typically returns empty because eBay loads results asynchronously — text extraction is more reliable.

**Two failure modes to expect — eBay is bot-aware:**

1. **Anti-bot interstitial.** eBay sometimes serves a "Pardon Our Interruption" / "Checking your browser" page instead of results. This does **not** throw an error — you just get a page with no sold rows. So treat *empty or suspiciously thin results as a possible block, not as a real "no comps" signal.* The interstitial behaves like a **soft, time-based challenge that often auto-resolves**, so recover in this order:
   - **First, wait it out in place:** `browser_wait_for` ~4s on the same page, then re-extract — the splash frequently clears itself without a reload. This is the cheapest fix and is what worked in testing.
   - If still blocked: `browser_close` → re-`browser_navigate` to the same URL → wait ~4s → re-extract.
   - Still blocked after that: fall back to a `WebFetch`/`WebSearch` of the same sold-listings query (no browser fingerprint).
   Only emit the "no comps found" result once you've confirmed it isn't a block.
2. **"Browser is already in use" / session lock.** This shows up when browser calls share a turn with other tool calls (e.g. image `Read`s during a listing run). Keep browser calls in their own turn. If the session locks or drops to `about:blank`, `browser_close` and re-navigate. Useful fallback: `browser_navigate` auto-writes an accessibility snapshot `.yml` to disk that already contains the rendered sold rows, which works when `browser_evaluate` keeps throwing. **Do not `Read` the whole `.yml`** — these snapshots run thousands of lines and would blow your context (defeating the 25 KB cap above). Instead `grep` it for just the sold rows, e.g. `grep -iE '\$[0-9]+|sold' <snapshot>.yml | head -60`, and parse those lines.

Parse the returned text for: date sold, title, condition (Pre-Owned vs Brand New vs New Other), price.

**Separate** used (Pre-Owned) comps from new comps. Used comps drive the recommendation; new comps are reference only.

### 2. Facebook Marketplace active listings (OPT-IN, skip by default)

**Skip this step by default.** eBay sold prices are the truth — FB Marketplace active listings are mostly noise (unsold items priced too high) and the extra web search costs 5–10s of latency.

Only run FB Marketplace research when:
- The user explicitly asks for local-market context
- eBay returned fewer than 3 usable sold comps and you need cross-platform reference data
- The orchestrator passes a flag indicating cross-platform analysis is needed

When you do run it, use a single web search:

```
{item name} used Facebook Marketplace {state/region} {current year} price
```

Search-result snippets are usually sufficient for a rough local price band. FB Marketplace requires login for deep browsing, so don't attempt browser automation.

### 3. Retail (new) reference

Quick web search for the item's manufacturer MSRP (the official "originally retails for" price). One sentence is enough — this is the canonical anchor for "Retails new for $X" framing in the listing copy.

**Canonical source rule:** the manufacturer's MSRP (from the brand's own site or a primary authorized retailer) is the **single** number that fills the listing's retail callout. If you find a different "current new street price" (e.g., the item is on eBay Brand New for less than MSRP), include it as a separate reference row in the analysis table — but the listing copy always uses MSRP.

**If the manufacturer's own site blocks you** (many return 403 to `WebFetch`), don't get stuck — the goal is the number, not the source. Take the MSRP from web-search snippets or a primary authorized retailer instead, and label the row accordingly (e.g., `Strider Bikes (MSRP, 12 Sport)`). A consistent figure corroborated across authorized retailers is a fine canonical anchor.

This avoids the awkward case where the listing says "Retails new for $139.99" while the analysis table also shows the item sold New for $119.99 — readers should be able to see both prices but the listing body anchors to the higher, more authoritative MSRP.

### 4. Synthesize (deterministic — use the calculator, don't eyeball)

Feed the **Pre-Owned** sold-item prices into the shipped calculator and use its numbers verbatim:

```
node ${CLAUDE_PLUGIN_ROOT}/scripts/price.js --condition "{condition}" {p1} {p2} {p3} ...
# add --heterogeneous if the comp set mixes incompatible variants (e.g. Sport vs Pro vs Classic)
```

It removes outliers with **Tukey fences (1.5·IQR)**, then reports from the cleaned set:
- **Median** + **IQR** (robust to skew — use these, not a mean that one high sale drags around)
- **List price** = condition-adjusted 75th-percentile (robust "upper cluster"); **Drop price** = condition-adjusted median
- **Confidence** tier (high / medium / low) from comp count + dispersion, capped by `--heterogeneous`
- The **percentile** where the list price sits among the sold comps

There is **no LLM in the pricing math** — the numbers are deterministic and reproducible. If the script can't run (no Bash/Node), apply the identical method by hand per `${CLAUDE_PLUGIN_ROOT}/references/pricing-method.md`. Never eyeball or fabricate the stats.

**Item-specific defects** not captured by the condition grade (a puncture, a missing part, a dead pixel) warrant a small **manual markdown below** the calculator's list price — state it explicitly (e.g. "$72 baseline, −$7 for the seat puncture → **$65**"). No synthetic local-pickup premium is baked into the number; any pickup framing is qualitative prose in the recommendation.

## Output format

Emit **two separate Markdown blocks** that the orchestrator slots directly into `{PRICING_ANALYSIS_BLOCK}` and `{PRICING_RECOMMENDATION_BLOCK}` in `${CLAUDE_PLUGIN_ROOT}/templates/listing.md`. Use h2 (`##`) for the top-level headings so they sit at the right level in the final file.

### Block 1: Pricing Analysis (fills `{PRICING_ANALYSIS_BLOCK}`)

```markdown
## Pricing Analysis

### Retail (New)
| Source | Price | Type |
|---|---|---|
| {manufacturer name} | {MSRP} | MSRP (canonical) |
| {other source, optional} | {street price} | Current new street price |

### eBay — Sold Listings ({short item name})
*Source: Live eBay sold listings pulled via browser automation, {Month D, YYYY}*

| Date Sold | Description | Condition | Price |
|---|---|---|---|
| {date} | {title} | Pre-Owned | ${price} |

**Used Range (cleaned):** ${min} – ${max}  ·  **Median:** ${median}  ·  **Q1–Q3:** ${q1} – ${q3}
**Confidence:** {high | medium | low} — {N} Pre-Owned comps{, {M} outlier(s) removed via Tukey fences}

### Facebook Marketplace — Active Listings ({location}, {Month D, YYYY})    ← OMIT this entire subsection if step 2 was skipped (default)
*Source: Live FB Marketplace browser search*

| Price | Description | Location | Notes |
|---|---|---|---|
| ${price} | {description} | {city} | {notes} |

**FB Marketplace Range:** ${min} – ${max}
```

### Block 2: Pricing Recommendation (fills `{PRICING_RECOMMENDATION_BLOCK}`)

```markdown
## Pricing Recommendation

| Scenario | Recommended Price | Rationale |
|---|---|---|
| List price | **${list}** | {rationale} |
| No bites in 7–10 days | **${drop}** | {rationale} |

**Final listing price: ${list}**   ·   *basis: condition-adjusted P75 / median of {N} cleaned sold comps · confidence {tier}*

> {one-paragraph context: why this price, the condition factor applied, any item-specific defect markdown, demand/color signals, and how much to trust the comp set given its confidence tier}
```

Return both blocks in your response, clearly labeled (`### Block 1` / `### Block 2`) so the orchestrator can extract them.

## Verification

Before returning, confirm every item — these are the invariants a comps run must not violate:

- **Live data only** — every price came from the browser scrape or the web-search fallback, never from training data or memory.
- **Prices, not fabrications** — if data was genuinely absent you emitted the honest "no comparable sales data found" block; you did not invent a single price.
- **Empty ≠ no-comps** — before emitting "no comps found," you confirmed a thin/empty page was not an anti-bot interstitial (waited in place, then retried/closed, then fell back to web search).
- **Used drives the recommendation** — Pre-Owned comps are separated from new comps, and the list/drop prices derive from the used set only.
- **Deterministic stats** — the range / median / list / drop came from `scripts/price.js` (or the identical by-hand method in `references/pricing-method.md`), not eyeballed; outliers were removed via Tukey fences and a confidence tier is stated. Any item-specific defect markdown is called out separately.
- **MSRP is the single retail anchor** — the retail callout uses manufacturer MSRP; any "current new street price" appears only as a separate reference row, never as the callout.
- **Research date cited** — the analysis block states the pull date inline in `Month D, YYYY` format.
- **Two labeled blocks** — output is exactly Block 1 (Pricing Analysis) and Block 2 (Pricing Recommendation), each with an h2 heading, nothing outside them.

## Notes

- Always cite the research date inline (format: `Month D, YYYY`, e.g., `June 8, 2026`) so future readers know when comps were pulled. Use the same format as the orchestrator's `{RESEARCH_DATE}` placeholder in the file footer.
- If fewer than 3 Pre-Owned sold comps exist for the exact model, broaden to model family (e.g., "Strider 12" instead of "Strider 12 Sport") and note the broadened scope
- When a query returns many comps (15+), don't dump every row into the table — it bloats the listing file. Show a **representative core cluster** (the comps that actually set the range), then add one line stating the total count and what you excluded as outliers, e.g. *"30 Pre-Owned comps returned; table shows the core cluster. Excluded: three sub-$25 parts/damaged units and three high-shipping bundles."* The median, range, and confidence should still reflect the full usable Pre-Owned set (fed to `scripts/price.js`), not just the rows shown.
- If only new comps exist (rare item, no resale history), recommend pricing at 40–60% of retail as a starting point and flag the lack of resale data
- **If no comps and no retail exist** (obscure item, generic part, custom build): emit the blocks with empty tables and a `> ⚠️ No comparable sales data found.` note in the Pricing Recommendation context paragraph. Recommend the user (a) try a different query phrasing, (b) check niche forums/subreddits for the item category, or (c) list at an exploratory price and adjust based on inquiries. Do not invent prices.
- Heavy browser output should be processed in a subagent if possible to keep the main context clean

