offerup.com logo
offerup.com

search-item

Installation

Adds this website's skill for your agents

 

Summary

Search the OfferUp marketplace for a query and return current for-sale listings (title, price, location, condition, item URL, image, delivery flags) by fetching the server-rendered search page and parsing its embedded __NEXT_DATA__ feed. Read-only.

FIG. 01
FIG. 02
FIG. 03
SKILL.md
197 lines

OfferUp Marketplace Item Search

Purpose

Search the OfferUp marketplace for a query term and return the current for-sale listings — each listing's title, price, location, condition, item-detail URL, thumbnail image, and delivery flags (local pickup / shipping). Read-only; never contacts a seller, makes an offer, or posts. Results are geo-scoped to the request's location (OfferUp derives location from the request IP — see gotchas).

When to Use

  • "What's for sale on OfferUp matching <query> near me?"
  • Price-comparison / deal-monitoring across a query with a min/max price band.
  • Bulk extraction of marketplace listings for a search term, optionally filtered by category, condition, price, and sort order.
  • Any flow that needs the listing grid (title/price/location/URL) without needing to open each item detail page.

Workflow

OfferUp's search page (/search?q=...) is a server-rendered Next.js page: the full listing feed is embedded in the page's <script id="__NEXT_DATA__"> JSON blob, produced by an internal modularFeed GraphQL query that runs server-side during SSR. This means the fastest, cheapest, most reliable path is a single proxied HTTP GET of the search URL followed by parsing that JSON — no scripted browser, no clicking, no waiting for client-side hydration. The site is behind Cloudflare (WAF), but the Browserbase Fetch API with --proxies sails through and returns the real SSR HTML (HTTP 200), not a challenge page.

1. Fetch the search page over a residential proxy

browse cloud fetch "https://offerup.com/search?q=<url-encoded query>" --proxies

The response is a JSON envelope: { id, statusCode, headers, contentType, encoding, content }. The rendered HTML is in .content. --proxies is important: a bare (non-proxy) fetch also returns 200 with SSR data, but it is geo-located to a datacenter (e.g. "Boardman, OR") and returns a degraded feed (~9 listings). The proxied fetch returns the full feed (~44–50 tiles) from a residential location.

2. Extract and parse __NEXT_DATA__

Pull the JSON out of .content:

<script id="__NEXT_DATA__" type="application/json">{ ... }</script>

The listings live at:

props.pageProps.searchFeedResponse.looseTiles[]

Each entry is a "tile" with a __typename. Filter to __typename === "ModularFeedTileListing" — the looseTiles array is a mixed feed that also contains ModularFeedTileGoogleDisplayAd, ModularFeedTileSellerAd, and (on empty searches) ModularFeedTileSearchAlert. Only listing tiles carry a real item.

For each listing tile, read tile.listing:

FieldSource
listing_idlisting.listingId
titlelisting.title
pricelisting.price (string, no currency symbol; may be "115" or "200.00"; null/absent for free/contact-for-price)
locationlisting.locationName (e.g. "Providence, RI")
conditionlisting.conditionText (often null)
image_urllisting.image.url
flagslisting.flags (e.g. ["LOCAL_PICKUP"], ["SHIPPING"])
vehicle_mileslisting.vehicleMiles (vehicles only, else null)
is_firm_pricelisting.isFirmPrice

3. Build the canonical item URL

https://offerup.com/item/detail/<listingId>

4. Read the resolved location and result count

  • Derived location: props.pageProps.searchFeedResponse.feedOptions[0].labelShort (e.g. "Westwood"), or the page <title> ("Search: <q> near <City, ST> - OfferUp").
  • Result count: number of ModularFeedTileListing tiles you extracted.

5. Optional filters — append URL query params (all verified working)

ParamValuesNotes
qurl-encoded queryrequired
price_minintegerinclusive lower bound
price_maxintegerinclusive upper bound
sortbest_match (default), -posted (newest), distance, price (low→high), -price (high→low)
cidcategory id: 1=Electronics & Media, 2=Home & Garden, 3=Clothing/Shoes/Accessories, 4=Baby & Kids, 5=Vehicles, 6=Toys/Games/Hobbies
radius5,10,20,30,50 (miles)distance from the resolved location

Example (iPhones $200–$400, cheapest first): https://offerup.com/search?q=iphone&price_min=200&price_max=400&sort=price

Browser fallback

If the Fetch API is unavailable or you specifically need client-side behavior, drive a stealth remote session instead. This is ~100× more expensive and unnecessary for read-only extraction, but it works:

sid=$(browse cloud sessions create --keep-alive --verified --proxies \
  | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).id))")
export BROWSE_SESSION="$sid"
browse open "https://offerup.com/search?q=iphone" --remote
browse wait load
browse wait timeout 3000        # feed hydrates 1–3s after load
browse get markdown body        # listings render under "## Current listings"
browse cloud sessions update "$sid" --status REQUEST_RELEASE

Both --verified and --proxies are required for the interactive browser path (bare sessions can hit the Cloudflare WAF challenge). Extract the same __NEXT_DATA__ JSON from browse get html body, or read the rendered markdown links […TITLE$PRICELOCATION](/item/detail/<uuid>).

Site-Specific Gotchas

  • Location is IP-based and NOT controllable via URL params. OfferUp derives the search location from the request's source IP (the residential proxy exit). lat=/lon= query params are silently ignored — verified: a request with LA coordinates still returned Davis/Sacramento CA results because that was the proxy exit. There is no zip= / postal= override on the web search URL. Proxy exits rotate per request, so the resolved city can differ between two identical fetches (observed: Westwood MA → Davis CA → Encino CA across runs). To target a specific metro you would need a proxy pinned to that region, or the interactive UI location picker (which persists location in session state, not reproducible via a stateless fetch). Always read back and report the resolved location (feedOptions[0].labelShort or the page title).
  • looseTiles is a mixed feed — always filter by __typename. ~44 of 50 tiles are ModularFeedTileListing; the rest are ModularFeedTileGoogleDisplayAd (1) and ModularFeedTileSellerAd (~5, sponsored). Sponsored/ad tiles do not carry a normal listing object. Treating every tile as a listing yields ad garbage.
  • No-results is a distinct, non-error shape. A query with no matches returns HTTP 200 with looseTiles containing a single ModularFeedTileSearchAlert tile and zero ModularFeedTileListing tiles. This is success: true, result_count: 0, not a failure.
  • price is a bare string with no currency symbol"115", "200.00". Free / contact-for-price items may have null or an absent price. Don't assume it parses as a clean integer.
  • conditionText is usually null even though the site exposes a Condition filter. Condition is a search filter (NEW,OPEN_BOX,REFURBISHED,USED,BROKEN,OTHER via CONDITION) more than a per-tile field.
  • Pagination is NOT via a page/offset URL param. ?page=2 returns an empty feed (0 listing tiles). The feed uses a searchFeedResponse.nextPageCursor (a long gzip+base64 token) consumed by the modularFeed GraphQL query on infinite scroll. The stateless SSR fetch only yields the first page (~44–50 listings). For deeper results you must replay the modularFeed GraphQL operation (POST /api/graphql) with the cursor plus the client headers (x-ou-d-token, x-ou-session-id, x-ou-usercontext) — not attempted here; first-page depth is sufficient for typical item-search use.
  • The search feed GraphQL op runs server-side, not in the browser. Watching the browser network tab you'll only see auxiliary ops (GetConfigs, GetUserLocationWithId, GetPrivacyStates, GetFederatedLoginInfo) hit POST /api/graphql — the actual modularFeed search query is executed during SSR and its result is baked into __NEXT_DATA__ / initialApolloState. Don't waste time looking for a client-side search XHR to intercept; parse __NEXT_DATA__ instead.
  • Cloudflare WAF is present but not blocking the SSR document. The homepage probe reports cloudflare + cloudflare-waf, and the HTML embeds the cdn-cgi/challenge-platform script, but the /search document itself returns 200 with real data over the Browserbase Fetch API (--proxies) and over a --verified --proxies browser session. No captcha was encountered in testing. A bare browser session (no verified/proxies) is the risky path — prefer stealth if you must use the browser.
  • statics.offerup.com build hash in asset URLs changes on deploys (2026.38.3.0-…) — don't hardcode it; you never need it for search extraction.

Expected Output

{
  "success": true,
  "query": "iphone",
  "resolved_location": "Providence, RI",
  "sort": "best_match",
  "result_count": 44,
  "listings": [
    {
      "listing_id": "ea75335d-31b2-3e45-93e9-ebc52607b67e",
      "title": "iPhone 11",
      "price": "115",
      "location": "Providence, RI",
      "condition": null,
      "flags": ["LOCAL_PICKUP"],
      "image_url": "https://images.offerup.com/48Q1gt4ZOPO3GYqqCGEanQ_9fWQ=/250x444/65a0/65a0d70b0484414f9d9a16acd76af4c9.jpg",
      "url": "https://offerup.com/item/detail/ea75335d-31b2-3e45-93e9-ebc52607b67e"
    }
  ],
  "error_reasoning": null
}

No results (valid, non-error):

{
  "success": true,
  "query": "zzqxwlkjhgfdsa123notreal",
  "resolved_location": "Encino, CA",
  "result_count": 0,
  "listings": [],
  "error_reasoning": null
}

Blocked (only if both fetch and stealth browser fail to return the SSR document):

{
  "success": false,
  "query": "iphone",
  "result_count": 0,
  "listings": [],
  "error_reasoning": "Cloudflare challenge returned instead of __NEXT_DATA__ document"
}