๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now โ†’
Guides

How to Scrape eBay Listings and Prices

Learn how to scrape eBay listings and prices: extract title, price, bids, condition, and sold data, handle search pagination, and get past eBay anti-bot checks.

S SparkProxy 1 16 min read
Share
How to Scrape eBay Listings and Prices

To track prices on eBay at any real scale you have to scrape eBay listings and the numbers attached to them: current price, bids, condition, seller, shipping, and what comparable items actually sold for. This guide covers the legal ground rules, the official eBay API you should reach for first, every field on a listing card, search pagination, the sold and completed archive, and how to get past eBay's anti-bot checks. The code is Python, the targets are public pages, and the endpoints are real.

Scrape eBay Listings Within the Rules

Public listing pages are the data you can collect responsibly. A search results page and an active item page are served to anyone without a login, and the facts on them (price, condition, shipping cost) are not personal data. That is different from harvesting buyer identities, scraping behind a signed-in account, or replaying data in a way that breaks eBay's User Agreement. Keep three habits and you stay on the defensible side:

  • Read robots.txt first. Fetch https://www.ebay.com/robots.txt and honor the disallowed paths. The search endpoint you will use here is public, but check before you widen the crawl.
  • Throttle hard. A human does not open 240 listings a second. Add delays and cap concurrency so you never degrade the site for real users.
  • Take facts, not people. Collect the listing attributes you need for pricing. Skip anything that ties a real person to behavior, and cache aggressively so you request each page once.

Pricing intelligence is the most common reason teams do this. If you are building a repricer or a competitor tracker, the workflow patterns in how ecommerce companies use proxies for competitive intelligence show where eBay data fits alongside other marketplaces.


Try the Official eBay API First

Before you write a single selector, check whether eBay will just hand you the data. The Browse API returns active listings and item details as clean JSON, it is free to register for, and it will never get your IP challenged. Reach for scraping only when the API cannot answer your question.

The one thing the standard API does not give you freely is sold-price history, which is exactly what most pricing work needs. Here is how the options compare:

APIWhat it returnsSold prices?Access
Browse APIActive listing search and item detailNoApplication OAuth token, open to all developers
Marketplace Insights APIItems sold in the last 90 daysYesLimited Release, business approval required
Feed APIBulk snapshots of active listingsNoApproval, aimed at large catalogs
Finding API (legacy)Old search, once included `findCompletedItems`Was yesBeing retired, do not build new work on it

Getting a Browse API token is a two-step OAuth flow. First trade your app credentials for an application access token:

import base64, requests

client_id = "YourApp-PRD-xxxx"
client_secret = "PRD-xxxxxxxx"
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()

token = requests.post(
    "https://api.ebay.com/identity/v1/oauth2/token",
    headers={
        "Authorization": f"Basic {basic}",
        "Content-Type": "application/x-www-form-urlencoded",
    },
    data={
        "grant_type": "client_credentials",
        "scope": "https://api.ebay.com/oauth/api_scope",
    },
).json()["access_token"]

Then search active listings. Note the X-EBAY-C-MARKETPLACE-ID header, which pins the request to a specific eBay site:

r = requests.get(
    "https://api.ebay.com/buy/browse/v1/item_summary/search",
    headers={
        "Authorization": f"Bearer {token}",
        "X-EBAY-C-MARKETPLACE-ID": "EBAY_US",
    },
    params={"q": "mechanical keyboard", "limit": 50},
)
for item in r.json().get("itemSummaries", []):
    print(item["title"], item["price"]["value"], item["price"]["currency"])

If the Browse API covers your use case, stop here. You get structured ebay product data without maintaining a single CSS selector. People still scrape the public site for two reasons: sold-price history sits behind the Marketplace Insights approval wall, and the free API quota is too small for wide, frequent sweeps. The rest of this guide is for those cases.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

What Data an eBay Listing Holds

Every eBay search card carries the same core fields, and each maps to a stable CSS class inside the li.s-item container. eBay does rotate its markup, so treat these selectors as a starting point and re-verify them when a scrape returns empty:

FieldSelector on a search cardExample valueNotes
Title`.s-item__title`"Keychron K8 Wireless Mechanical Keyboard"The first card is a "Shop on eBay" placeholder, skip it
Price`.s-item__price`"$89.99" or "$79.00 to $99.00"Variable listings show a range, parse both bounds
Bids`.s-item__bids`"7 bids"Present only on auctions, absent on Buy It Now
Condition`.SECONDARY_INFO`"Brand New", "Pre-Owned"A text label, not a numeric grade
Seller`.s-item__seller-info-text`"techdeals (12,345) 99.2%"Username, feedback count, positive percent
Shipping`.s-item__shipping`"Free shipping" or "+$5.99 shipping"Map "Free" to 0 during cleanup
Sold count`.s-item__quantitySold`"1,024 sold"A marketing badge on active items, not the sold archive

Two of these trip people up. The sold count on an active listing ("1,024 sold") is a lifetime popularity badge, not a record of individual sales. The sold archive (what a specific unit closed at, and when) lives behind the sold-listings filter covered later. And condition is eBay's own label, so normalize the strings ("Pre-Owned", "Used", "Open Box") into your own enum before you compare across listings.


Set Up the Scraper

Two libraries are enough: requests for fetching and parsel for CSS selection. parsel is the same selector engine Scrapy uses, and its .css() and ::text syntax reads cleanly.

pip install requests parsel

Send a real browser User-Agent and an Accept-Language header. eBay serves different markup and currency to a bare Python client, and a missing language header is one of the first bot signals it checks:

import requests
from parsel import Selector

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
    ),
    "Accept-Language": "en-US,en;q=0.9",
}

url = "https://www.ebay.com/sch/i.html?_nkw=mechanical+keyboard&_ipg=240"
html = requests.get(url, headers=HEADERS, timeout=20).text
sel = Selector(text=html)

_ipg=240 asks for 240 items per page, the maximum eBay allows, which cuts the number of requests you make by a factor of four versus the default 60.


Parse a Single Listing Card

Pull the fields from the table into a dictionary, then filter out the placeholder card eBay injects at the top of every result set:

def parse_card(card):
    return {
        "title": card.css(".s-item__title::text").get(),
        "price": card.css(".s-item__price::text").get(),
        "bids": card.css(".s-item__bids::text").get(),
        "condition": card.css(".SECONDARY_INFO::text").get(),
        "seller": card.css(".s-item__seller-info-text::text").get(),
        "shipping": card.css(".s-item__shipping::text").get(),
        "sold": card.css(".s-item__quantitySold::text").get(),
        "url": card.css("a.s-item__link::attr(href)").get(),
    }

cards = sel.css("li.s-item")
listings = [parse_card(c) for c in cards]
# eBay makes the first card a "Shop on eBay" placeholder, so drop it
listings = [l for l in listings if l["title"] and l["title"] != "Shop on eBay"]

Prices come back as messy strings, and you cannot compare "$79.00 to $99.00" to "$89.99" until you parse them into numbers. This helper handles single prices, ranges, and thousands separators, and it is where you scrape eBay prices into something you can actually sort on:

import re

def parse_price(text):
    if not text:
        return None
    text = text.replace(",", "")
    nums = re.findall(r"\d+\.\d{2}", text)
    if not nums:
        return None
    low, high = float(nums[0]), float(nums[-1])   # "$79.00 to $99.00" -> two values
    return {"low": low, "high": high, "is_range": low != high}

Run every raw price string through parse_price before you store it. A range keeps both bounds so downstream math (median, min, spread) stays honest instead of silently dropping the high end.


Scrape Search Results and Pagination

eBay's search URL is fully query-driven, which means you can build any view without clicking through the UI. These are the parameters that matter:

ParameterPurposeExample
`_nkw`Search keywords`_nkw=mechanical+keyboard`
`_pgn`Page number`_pgn=3`
`_ipg`Items per page (60, 120, 240)`_ipg=240`
`_sop`Sort order (13 = newly ended)`_sop=13`
`LH_BIN`Buy It Now only`LH_BIN=1`
`LH_Sold`Sold items only`LH_Sold=1`
`LH_Complete`Completed listings`LH_Complete=1`

Walk the pages by incrementing _pgn until a page returns no cards, which is the natural stop signal:

import time

def scrape_all_pages(keyword, max_pages=10):
    results = []
    for page in range(1, max_pages + 1):
        url = (
            f"https://www.ebay.com/sch/i.html?_nkw={keyword}"
            f"&_ipg=240&_pgn={page}"
        )
        html = requests.get(url, headers=HEADERS, timeout=20).text
        cards = Selector(text=html).css("li.s-item")
        page_items = [
            parse_card(c) for c in cards
            if c.css(".s-item__title::text").get() not in (None, "Shop on eBay")
        ]
        if not page_items:
            break                     # no more results, stop early
        results.extend(page_items)
        time.sleep(2)                 # stay polite, avoid rate limits
    return results

One eBay-specific limit to plan around: a single query stops returning fresh results after roughly 10,000 items, no matter how high you push _pgn. If your category is bigger than that, split it. Add a price band (_udlo and _udhi) or a sub-category so each query stays under the ceiling, then merge and de-duplicate the results by item ID afterward.


Scrape Sold and Completed Listings

Sold listings are the gold in ebay scraping, because a closed sale is the only price that proves what a buyer actually paid. Add two filters to any search to switch from active items to the sold archive:

# Sold and completed listings: LH_Sold=1 and LH_Complete=1
sold_url = (
    "https://www.ebay.com/sch/i.html"
    "?_nkw=mechanical+keyboard"
    "&LH_Sold=1&LH_Complete=1"
    "&_ipg=240&_sop=13"          # _sop=13 sorts by most recently ended
)

On these pages the price shown is the final sale price, and each card carries the date it ended. The card structure is the same li.s-item layout, so your existing parse_card works without changes. Two constraints shape what you get. The public sold filter only retains roughly the last 90 days, and eBay increasingly gates deeper sold history behind a signed-in view, so scrape it soon after sales close rather than expecting a long tail.

The sanctioned route for the same data is the Marketplace Insights API, which returns sold items from the last 90 days as JSON. It is a Limited Release that needs business approval, but if you can get it, it is far more stable than parsing HTML:

# Sanctioned sold-data route (Limited Release, requires eBay approval)
r = requests.get(
    "https://api.ebay.com/buy/marketplace_insights/v1_beta/item_sales/search",
    headers={
        "Authorization": f"Bearer {token}",
        "X-EBAY-C-MARKETPLACE-ID": "EBAY_US",
    },
    params={"q": "mechanical keyboard", "limit": 50},
)

If you have used eBay scraping tutorials from a few years ago, they likely called the Finding API's findCompletedItems. eBay has been retiring the Finding API, so do not build new pipelines on it. Marketplace Insights is its replacement, and the public sold filter is the fallback when you lack approval.


Get Past eBay's Anti-Bot Defenses

eBay fronts its pages with commercial bot management, so aggressive scraping trips challenges instead of returning HTML. The failures are usually easy to recognize once you know the pattern:

SymptomWhat it meansResponse
HTTP 429Your IP is rate-limitedSlow down, rotate IPs, back off
"Pardon Our Interruption" pageBot challenge servedRotate to a residential IP, render JS
Empty `.s-item` list on an HTTP 200Soft block or markup changeLog the raw HTML, re-check selectors
Redirect to a sign-in or captchaHard block on the IPSwitch IP pool, cut concurrency

Detect the soft failures explicitly. A 200 response with no listings is not success, it is a block wearing a disguise, and your parser will happily record zero results as if the query were empty:

def is_blocked(html):
    markers = ("Pardon Our Interruption", "Checking your browser", "unusual activity")
    return any(m.lower() in html.lower() for m in markers)

resp = requests.get(url, headers=HEADERS, timeout=20)
if resp.status_code == 429 or is_blocked(resp.text):
    print("Blocked or rate-limited, rotate the IP and back off")

The durable fixes are the same ones that keep any scraper alive: realistic headers, human-paced request timing, and a pool of clean IPs so no single address crosses eBay's threshold. Datacenter IPs handle the light pages; the hardest challenges need residential exits. For the full playbook on staying under the radar, see how to avoid getting your proxy blocked.


Scale Up with the SparkProxy Scraping API

Rotating your own proxy pool, retrying failures, and rendering JavaScript for challenge pages is real infrastructure to own. The SparkProxy Scraping API handles all three server-side: it rotates the exit IP on every request, can run a headless browser when a page needs it, and lets you pin the exit country. You send a target URL and get the HTML back.

import requests
from parsel import Selector

resp = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": "sk-xxxxxxxxxxxxxxxx"},   # key format: sk-...
    params={
        "url": "https://www.ebay.com/sch/i.html?_nkw=mechanical+keyboard&_ipg=240",
        "render_js": "false",     # eBay search HTML is server-rendered, 1 credit
        "country_code": "us",     # match the eBay marketplace you target
        "premium_proxy": "true",  # residential IPs for the hardest pages
    },
    timeout=60,
)
sel = Selector(text=resp.text)

Authentication is the X-API-Key header carrying a key of the form sk-... from your dashboard. Keep render_js off for eBay search pages, since they arrive server-rendered and a plain fetch costs 1 credit instead of 5. Turn premium_proxy on only when a target keeps challenging you, because residential IPs cost more per call. The practical split is to run cheap datacenter fetches for the wide, easy sweeps and reserve the API's residential path for the pages that fight back. If you are weighing that build-versus-buy decision in detail, web scraping API vs self-managed proxies lays out the cost and maintenance trade-offs.


Clean, Store, and Use the Data

Raw listings are not analysis. Three cleanup passes turn them into something you can price against:

  1. Normalize prices through parse_price so every row holds numbers, not strings.
  2. De-duplicate by item ID parsed from the url field, because the same listing can appear on more than one page during a long crawl.
  3. Map shipping so "Free shipping" becomes 0.0 and folds into a landed-cost total.

Then write it out. CSV is enough to load into a sheet or a database:

import csv

with open("ebay_listings.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=listings[0].keys())
    writer.writeheader()
    writer.writerows(listings)

With sold listings normalized, the median closing price of the last 90 days is a far better anchor than any single active asking price. That number drives repricing, sourcing decisions, and demand tracking. If eBay is one input in a broader monitoring stack, the architecture in datacenter proxies for price comparison websites covers how to run the same collection across many retailers at once.


Frequently asked questions

FAQ

Scraping public listing pages for facts like price, condition, and shipping is generally defensible, especially for non-personal data. It is not a blanket permission slip. Respect robots.txt, throttle your requests, avoid collecting personal information, and review eBay's User Agreement for your specific use. Signed-in scraping and buyer data are a different, riskier category.

Yes. The Browse API returns active listings and item details as JSON with just an application OAuth token, and it is the cleanest way to pull ebay product data. Sold-price history is separate: it lives in the Marketplace Insights API, which is a Limited Release requiring business approval. Try the API before scraping the site.

Add LH_Sold=1&LH_Complete=1 to the search URL to switch from active items to the sold archive, where each card shows the final sale price and end date. The public filter retains roughly the last 90 days. The sanctioned alternative is the Marketplace Insights API, which returns the same sold data as JSON once eBay approves your application.

Most eBay scraping blocks come from too many requests from one IP, a missing or fake-looking User-Agent, or no Accept-Language header. Watch for HTTP 429, a "Pardon Our Interruption" page, and 200 responses with an empty listing set, which is a soft block. Rotate through clean IPs, pace requests like a human, and escalate to residential exits for the pages that still challenge you.

A search-card scrape yields title, price, bids, condition, seller username and feedback, shipping cost, and the sold-count badge. An item-page scrape adds full description, item specifics, images, quantity available, and the listing format. A good ebay data scraper normalizes conditions and prices so the fields are comparable across thousands of listings.

There is no published number, so treat it empirically. A safe start is one request every one to two seconds per IP, with _ipg=240 to get more items per page. A single query also stops returning new results past roughly 10,000 items, so split large categories by price band or sub-category rather than paging deeper.


Limited-time ยท 50% off

Get 50% off your first purchase

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Offer ends soon โ€” claim it before it's gone

Claim Discount

About the Author

This guide was written by the SparkProxy Technical Team. SparkProxy operates datacenter and residential proxy networks and a managed Scraping API used by engineering teams for web scraping, price monitoring, and large-scale market research. We build and maintain the rotation, geo-targeting, and anti-bot infrastructure described here, and we publish these guides from hands-on work against the same marketplace defenses our customers run in production. For product details and endpoints, see the SparkProxy Scraping API docs.

Keep reading

Related articles

How to Scrape a Website Behind a Login

How to Scrape a Website Behind a Login

Scrape website behind login walls, the right way: persist session cookies, handle CSRF tokens, log in with Playwright, hold sticky proxies, use SparkProxy API.

SparkProxyยทGuides
How to Scrape Trustpilot Reviews with Proxies

How to Scrape Trustpilot Reviews with Proxies

Learn to scrape Trustpilot reviews with proxies: extract rating, text, date, reviewer, and company replies as JSON, paginate at scale, and stay GDPR-compliant.

SparkProxyยทGuides