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.

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.txtfirst. Fetchhttps://www.ebay.com/robots.txtand 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:
| API | What it returns | Sold prices? | Access |
|---|---|---|---|
| Browse API | Active listing search and item detail | No | Application OAuth token, open to all developers |
| Marketplace Insights API | Items sold in the last 90 days | Yes | Limited Release, business approval required |
| Feed API | Bulk snapshots of active listings | No | Approval, aimed at large catalogs |
| Finding API (legacy) | Old search, once included `findCompletedItems` | Was yes | Being 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.
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:
| Field | Selector on a search card | Example value | Notes |
|---|---|---|---|
| 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:
| Parameter | Purpose | Example |
|---|---|---|
| `_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:
| Symptom | What it means | Response |
|---|---|---|
| HTTP 429 | Your IP is rate-limited | Slow down, rotate IPs, back off |
| "Pardon Our Interruption" page | Bot challenge served | Rotate to a residential IP, render JS |
| Empty `.s-item` list on an HTTP 200 | Soft block or markup change | Log the raw HTML, re-check selectors |
| Redirect to a sign-in or captcha | Hard block on the IP | Switch 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:
- Normalize prices through
parse_priceso every row holds numbers, not strings. - De-duplicate by item ID parsed from the
urlfield, because the same listing can appear on more than one page during a long crawl. - Map shipping so "Free shipping" becomes
0.0and 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.
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
Related articles

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.

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.

Java Web Scraping Proxy: Jsoup and HttpClient Guide
Java web scraping proxy guide: set Jsoup.connect proxies, fix the HTTPS auth gotcha, rotate IPs, parse pages with selectors, and call the SparkProxy API.
