Three eBay scraping jobs in Python: keyword search results, one listing by URL, and a seller profile by store URL. Every request here ran on 27 July 2026 against the Chocodata API, the screenshots are that output, and the field counts below are what actually came back rather than what the docs promise.
Why is it hard to scrape eBay?
eBay serves three selling formats through one result set, and a single "nintendo switch" search returned 38 buy-it-now listings, 17 accepting offers and 2 live auctions, each with a different set of populated fields. Datacenter IPs get an interruption page in place of the listing, which parses to nothing rather than raising. The expensive problem is the optional fields: watchers appeared on 12 of 57 rows and bid counts on 4, so a parser written against the top of the page throws or silently writes nulls further down.
Prerequisites
Python 3.9 or newer and requests. No browser driver, no proxy pool, no account beyond a free key.
1. A free Chocodata API key. Free, no card, and usable the moment you land on the dashboard.
2. requests in a clean environment.
python -m venv .venv && . .venv/bin/activate
pip install requests
3. The eBay URL you want, either an /itm/ listing link or an /str/ store link. Tested with Python 3.13.7 and requests 2.34.2 in July 2026. Any language with an HTTP client works identically, so Node, Go and PHP need no special handling.
Scrape eBay search results
To scrape eBay search results, send the keyword as query to the search endpoint and read the results array off the top level of the response.
1. Send the keyword and check the payload
Wrap the call so an empty body raises instead of flowing into your parser as a zero-row run.
import time
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
def call(endpoint, tries=3, **params):
"""One GET, retried, so a batch run survives a dropped response."""
params["api_key"] = API_KEY
for attempt in range(tries):
r = requests.get(f"{BASE}/{endpoint}", params=params, timeout=60)
if r.ok:
break
time.sleep(2)
r.raise_for_status()
data = r.json()
if not data:
raise RuntimeError(f"{endpoint}: empty payload for {params}")
return data
search = call("ebay/search", query="nintendo switch")
print(search["query"], "| page", search["page"], "| rows", len(search["results"]))
print(len(search["results"][0]), "fields on the first listing")
nintendo switch | page 1 | rows 57
26 fields on the first listing
HTTP 200 in 2.69 s. There is no envelope around the payload, so it is search["results"] and not search["data"]["results"].
2. Count how many rows carry each optional field
Count the populated rows per field before writing any parsing code, because the eBay search response defines far more fields than any single listing fills.
from collections import Counter
rows = search["results"]
for field in ("rating", "old_price", "watchers", "quantity_sold", "bids"):
print(f"{field:<16} {sum(1 for r in rows if r.get(field))}/{len(rows)}")
print(Counter(r["buying_format"] for r in rows))
rating 22/57
old_price 16/57
watchers 12/57
quantity_sold 8/57
bids 4/57
Counter({'buy_it_now': 38, 'accepts_offers': 17, 'auction': 2})
Nothing below rating clears half the rows. Run this against your own keyword before you decide which fields your schema can require.
3. Normalise the rows and write a CSV
Coalesce the optional fields to zero at write time so downstream aggregation does not have to special-case None.
import csv
from datetime import date
FIELDS = ["scraped_on", "position", "id", "title", "price", "condition",
"buying_format", "watchers", "quantity_sold"]
def normalise(row):
return {"scraped_on": date.today().isoformat(),
"position": row["position"],
"id": row["id"],
"title": row["title"],
"price": row["price"],
"condition": row["condition"],
"buying_format": row["buying_format"],
"watchers": row.get("extracted_watchers") or 0,
"quantity_sold": row.get("extracted_quantity_sold") or 0}
with open("ebay_search.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(normalise(r) for r in rows)
print(len(rows), "rows written")
57 rows written
Use extracted_watchers and extracted_quantity_sold rather than their raw twins, since the extracted pair are already integers. The raw pair are display strings, which matters later. A single listing fills in much more than a search row does.
Pull a single eBay listing
To scrape one eBay listing, pass its URL as url to the product endpoint, with no item ID lookup in between.
1. Send the listing URL
The /itm/ link from the address bar is the whole input.
LISTING = "https://www.ebay.com/itm/327168915787"
item = call("ebay/product", url=LISTING)
print(len(item), "fields")
print(item["title"])
print(item["price"], item["currency"], "|", item["condition"])
16 fields
New Nintendo Switch OLED Model HEG-001 Handheld Console - White - New Other
294.99 USD | Open box
HTTP 200 in 2.08 s for 16 top-level fields.
2. Compute the discount and the stock position
Guard the discount maths, because old_price is absent on most listings and present only when eBay is showing a strikethrough.
def discount_pct(item):
old, new = item.get("old_price"), item.get("price")
if not old or not new or old <= new:
return 0.0
return round((old - new) / old * 100, 1)
print("was", item["old_price"], "now", item["price"],
"=>", discount_pct(item), "% off")
print("sold", item["quantity_sold"], "| available", item["quantity_available"],
"| in stock", item["availability"])
was 319.99 now 294.99 => 7.8 % off
sold 1 | available 1 | in stock True
On the listing endpoint quantity_sold is an integer, unlike its namesake on the search endpoint.
3. Flatten the nested identifiers
Collapse ids into a flat dict, because it arrives as a list of single-key objects rather than one object.
ids = {k: v for entry in item.get("ids", []) for k, v in entry.items()}
print(ids)
print("item_id", item["item_id"], "| brand", item["brand"])
print("description", len(item["description"]), "chars")
{'mpn': '115461'}
item_id 327168915787 | brand Nintendo
description 177 chars
additional_properties came back as an empty list on this listing, so treat it as optional too. Seller reputation is the last of the three jobs.
Profile an eBay seller
To scrape an eBay seller, send the store URL as url and read the reputation fields off the top level.
1. Send the store URL
Count the populated fields on arrival, since three of the fourteen come back empty for most stores.
STORE = "https://www.ebay.com/str/OfficialBestBuy"
seller = call("ebay/seller", url=STORE)
populated = sum(1 for v in seller.values() if v not in (None, [], {}))
print(len(seller), "fields |", populated, "populated")
print(seller["store_name"], "/", seller["username"])
print("feedback", seller["feedback_score"], "|",
seller["positive_feedback_percent"], "% positive")
14 fields | 11 populated
Best Buy / best_buy
feedback 983317 | 99 % positive
HTTP 200 in 4.81 s. top_rated_seller, categories and active_items_count were the three empty ones.
2. Compute the real positive rate from the raw counts
Recompute the positive rate from feedback_ratings rather than trusting the rounded headline percentage.
fb = seller["feedback_ratings"]
total = sum(fb.values())
print(fb)
print(f"{fb['positive'] / total * 100:.2f}% positive across {total} rated transactions")
print("negative", fb["negative"], "| neutral", fb["neutral"])
{'positive': 132670, 'neutral': 426, 'negative': 1389}
98.65% positive across 134485 rated transactions
negative 1389 | neutral 426
The headline said 99%, the raw counts say 98.65%. Over 134,485 transactions that gap is 1,389 unhappy buyers, which is the number you actually want in a supplier scorecard.
3. Score the four detailed ratings
Sort the ratings object ascending so the weakest dimension is the first thing printed.
ratings = seller["ratings"]
for name, score in sorted(ratings.items(), key=lambda kv: kv[1]):
print(f"{name:<18} {score:<5} {'#' * int(score * 4)}")
print("mean", round(sum(ratings.values()) / len(ratings), 2))
print("member since", seller["member_since"], "|", seller["items_sold"], "sold")
communication 4.6 ##################
item_as_described 4.7 ##################
shipping_speed 4.9 ###################
shipping_charges 5 ####################
mean 4.8
member since Jul 26, 2012 | 6100000 sold
Communication is the weak dimension at 4.6 against a 4.8 mean.
The part that breaks
quantity_sold on search rows is a display string. Casting it directly is the first thing that fails.
row = next(r for r in rows if r.get("quantity_sold"))
print(repr(row["quantity_sold"]))
int(row["quantity_sold"])
'2,595 sold'
Traceback (most recent call last):
File "ebay_search.py", line 81, in <module>
int(row["quantity_sold"])
~~~^^^^^^^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: '2,595 sold'
Use extracted_quantity_sold, which is already 2595.
bids is an object, not a count. It arrives as {'count': 0}, so arithmetic on it raises TypeError: unsupported operand type(s) for +: 'dict' and 'int'. Read row["bids"]["count"].
Page 2 repeats page 1. Requesting both pages for the same keyword returned 114 rows containing 97 unique ids, so 17 listings came back twice. Deduplicate on id before counting anything.
page 1: 57 rows
page 2: 57 rows
combined: 114 rows, 97 unique ids, 17 repeats
The seller slug is not the username. Passing /str/OfficialBestBuy returned username 'best_buy' and a canonical url of https://www.ebay.com/str/best_buy, so key your storage on username rather than on the URL you sent.
Result sets move between runs. The same two-page search returned 97 unique listings on one run and 102 twenty minutes later. Stamp every row with a date and compare snapshots rather than expecting a stable count.
Full script
"""Scrape eBay search results, one listing and a seller profile.
Tested on Python 3.13.7 with requests 2.34.2, July 2026.
"""
import csv
import time
import requests
from collections import Counter
from datetime import date
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
FIELDS = ["scraped_on", "position", "id", "title", "price", "condition",
"buying_format", "watchers", "quantity_sold"]
def call(endpoint, tries=3, **params):
"""One GET, retried, so a batch run survives a dropped response."""
params["api_key"] = API_KEY
for attempt in range(tries):
r = requests.get(f"{BASE}/{endpoint}", params=params, timeout=60)
if r.ok:
break
time.sleep(2)
r.raise_for_status()
data = r.json()
if not data:
raise RuntimeError(f"{endpoint}: empty payload for {params}")
return data
def normalise(row):
return {"scraped_on": date.today().isoformat(),
"position": row["position"],
"id": row["id"],
"title": row["title"],
"price": row["price"],
"condition": row["condition"],
"buying_format": row["buying_format"],
"watchers": row.get("extracted_watchers") or 0,
"quantity_sold": row.get("extracted_quantity_sold") or 0}
def search_listings(query, pages=2):
seen, rows = set(), []
for page in range(1, pages + 1):
data = call("ebay/search", query=query, page=page)
for row in data["results"]:
if row["id"] in seen:
continue
seen.add(row["id"])
rows.append(normalise(row))
return rows
def listing(url):
item = call("ebay/product", url=url)
old, new = item.get("old_price"), item.get("price")
item["discount_pct"] = (round((old - new) / old * 100, 1)
if old and new and old > new else 0.0)
return item
def seller_profile(url):
data = call("ebay/seller", url=url)
fb = data["feedback_ratings"]
total = sum(fb.values())
data["positive_rate"] = round(fb["positive"] / total * 100, 2)
data["rated_transactions"] = total
return data
if __name__ == "__main__":
rows = search_listings("nintendo switch")
with open("ebay_search.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(rows)
print(len(rows), "unique listings written")
print(Counter(r["condition"] for r in rows).most_common(3))
item = listing("https://www.ebay.com/itm/327168915787")
print(item["title"])
print(item["price"], item["currency"], "|", item["discount_pct"], "% off")
store = seller_profile("https://www.ebay.com/str/OfficialBestBuy")
print(store["store_name"], "|", store["positive_rate"], "% of",
store["rated_transactions"])
102 unique listings written
[('Pre-Owned', 79), ('Very Good - Refurbished', 6), ('Open Box', 5)]
New Nintendo Switch OLED Model HEG-001 Handheld Console - White - New Other
294.99 USD | 7.8 % off
Best Buy | 98.65 % of 134485
Summary
All three eBay jobs are a single GET with everything at the top level of the response: 57 search rows in 2.69 s, 16 listing fields in 2.08 s, and 14 seller fields in 4.81 s. What the API cannot give you is a populated field on every row, because eBay itself only publishes watchers, bids and sold counts on a minority of listings, and the search endpoint mirrors that faithfully. Take one habit from this into your own build and make it the extracted_* fields plus or 0, because the display strings look numeric right up to the point where int() raises on a comma.
FAQ
Is scraping eBay legal?
Public listing pages are not automatically illegal to scrape, but eBay's user agreement restricts automated collection, which makes it a contract question rather than a criminal one. Not legal advice.
Can I scrape an eBay listing without the item ID?
Yes, the /itm/ URL is the only input the product endpoint needs, so no ID lookup step is required.
Why does int(row["quantity_sold"]) fail on eBay search results?
Because that field is the display string '2,595 sold', and the integer you want is in extracted_quantity_sold.










Top comments (0)