DEV Community

Coco
Coco

Posted on

How to scrape eBay in 2026?

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.

Signing up for a free API key

2. requests in a clean environment.

python -m venv .venv && . .venv/bin/activate
pip install requests
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode
nintendo switch | page 1 | rows 57
26 fields on the first listing
Enter fullscreen mode Exit fullscreen mode

HTTP 200 in 2.69 s. There is no envelope around the payload, so it is search["results"] and not search["data"]["results"].

Search request returning 57 listings

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))
Enter fullscreen mode Exit fullscreen mode
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})
Enter fullscreen mode Exit fullscreen mode

Nothing below rating clears half the rows. Run this against your own keyword before you decide which fields your schema can require.

Field coverage counted across the result set

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")
Enter fullscreen mode Exit fullscreen mode
57 rows written
Enter fullscreen mode Exit fullscreen mode

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.

Normalised rows written to CSV

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"])
Enter fullscreen mode Exit fullscreen mode
16 fields
New Nintendo Switch OLED Model HEG-001 Handheld Console - White - New Other
294.99 USD | Open box
Enter fullscreen mode Exit fullscreen mode

HTTP 200 in 2.08 s for 16 top-level fields.

Single listing returned with 16 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"])
Enter fullscreen mode Exit fullscreen mode
was 319.99 now 294.99 => 7.8 % off
sold 1 | available 1 | in stock True
Enter fullscreen mode Exit fullscreen mode

On the listing endpoint quantity_sold is an integer, unlike its namesake on the search endpoint.

Discount and stock position for the listing

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")
Enter fullscreen mode Exit fullscreen mode
{'mpn': '115461'}
item_id 327168915787 | brand Nintendo
description 177 chars
Enter fullscreen mode Exit fullscreen mode

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.

Flattened identifiers from the listing

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")
Enter fullscreen mode Exit fullscreen mode
14 fields | 11 populated
Best Buy / best_buy
feedback 983317 | 99 % positive
Enter fullscreen mode Exit fullscreen mode

HTTP 200 in 4.81 s. top_rated_seller, categories and active_items_count were the three empty ones.

Seller profile returned from the store URL

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"])
Enter fullscreen mode Exit fullscreen mode
{'positive': 132670, 'neutral': 426, 'negative': 1389}
98.65% positive across 134485 rated transactions
negative 1389 | neutral 426
Enter fullscreen mode Exit fullscreen mode

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.

Positive rate recomputed from the raw feedback counts

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")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

Communication is the weak dimension at 4.6 against a 4.8 mean.

Detailed seller ratings sorted ascending

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"])
Enter fullscreen mode Exit fullscreen mode
'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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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)