A step-by-step guide to scraping eBay search results into structured item data.
We'll create a small program that includes:
An eBay search page parser - turns search HTML into structured listing data
An eBay search page fetcher - visits eBay home, then fetches a search page for a keyword
An eBay search result saver - stores cookies/session and parsed items for the next run
A CLI - run the parser on local HTML, or fetch + parse a live search
Sample HTML: sample.html · Session file: data/ebay_session.json
Local-first rule: Fetch HTML once and save it. Build and test the parser against that saved file - do not re-fetch while iterating on selectors.
scraper.py + httpxhome then search → sample.html / live HTML
↓
ebay_parse.py
↓
search_parser.py
↓
models/search.py
session_store.py
↓
data/ebay_session.json
↓
EbaySearchData
| Layer | Role |
|---|---|
| scraper.py | Live HTML fetch via httpx - home visit, then one keyword search |
| session_store.py | Save/load cookies and last parsed items |
| scripts/fetch_fixture.py | CLI to download one search HTML fixture |
| ebay_parse.py | Thin parse entrypoint: parse_ebay_search_page(html) |
| parsers/ | Thin parse entrypoint: parse_ebay_search_page(html) |
| models/search.py | Strict Pydantic schemas |
| main.py | Demo CLI - offline --html or live --search |
| main.py | Offline pytest suite against sample.html |
Windows:
Go to python.org/downloads
Click the yellow button that says "Download Python 3.12.x"
Run the downloaded .exe file
IMPORTANT: Check the box that says "Add Python to PATH" at the bottom
Click "Install Now" and wait for it to finish
macOS:
Go to python.org/downloads
Click the yellow button for "Download Python 3.12.x"
Open the downloaded .pkg file and follow the installer
Ubuntu / Debian Linux:
sudo apt update
sudo apt install python3.12 python3.12-venv
Fedora Linux:
sudo dnf install python3.12
Verify it worked:
Open a terminal and type:
python3.12 --version
You should see:
Python 3.12.x
uv is a fast tool that installs Python libraries. We use it instead of pip.
Windows (PowerShell):
powershell -c "irm <https://astral.sh/uv/install.ps1> | iex"
macOS / Linux:
curl -LsSf <https://astral.sh/uv/install.sh> | sh
source $HOME/.local/bin/env
After installing, close and reopen your terminal (or run the source line above to activate immediately).
Verify:
uv --version
Should show something like
uv 0.x.x
Linux only - pyright dependency:
pyright ships with a bundled Node binary that requires
libatomic
, which is absent on minimal Ubuntu/Debian installs. If
uv run pyright
fails with
libatomic.so.1: cannot open shared object file
, install it:
sudo apt install libatomic1
Open your terminal, navigate to the project folder, then run:
cd ebay
uv sync
This reads pyproject.toml and installs:
httpx - makes web requests
beautifulsoup4 + lxml - parses HTML
pydantic - handles data cleanly
Dev tools (installed automatically): pytest, ruff, pyright (strict mode).
Quality checks:
uv run pytest # all tests must pass
uv run ruff check . # lint
uv run pyright # strict type check
The parser works directly on HTML. You only need a search-results HTML file (like sample.html). No live request is required for this step.
uv run python main.py --html sample.html
# Show more rows
uv run python main.py --html sample.html --limit 10
# Optional label stored on the output object
uv run python main.py --html sample.html --keyword "shoes men" --limit 10
Args:
-html - path to a local eBay search HTML file (defaults to sample.html)
-limit - how many listings to print in the terminal (default: 5)
-keyword - optional label only for the output data object; it is not used to fetch or filter the HTML
What happens:
Read the HTML file from disk
parse_ebay_search_page() finds ul.srp-results > li.s-card cards
Print item count and the first few listings
Parsed from local file: sample.html
============================================================
Keyword: None
Source: .../sample.html
Items found: 60
------------------------------------------------------------
- 168520328526 | $999.99 | Off White Virgil Abloh Air Jordan 1 High VAA ...
<https://www.ebay.com/itm/168520328526>
Pre-Owned | Buy It Now | +$396.57 delivery | United States
------------------------------------------------------------
uv run python -c "
from bs4 import BeautifulSoup
html = open('sample.html').read()
soup = BeautifulSoup(html, 'lxml')
cards = soup.select('ul.srp-results > li.s-card')
print('cards:', len(cards))
title = cards[0].select_one('.s-card__title .su-styled-text.primary')
print('title:', title.get_text(strip=True)[:80])
print('price:', cards[0].select_one('.s-card__price').get_text(strip=True))
"
| Area | Selector / attribute | Parsed by |
|---|---|---|
| Results list | ul.srp-results | search_parser.py |
| Listing card | li.s-card | search_parser.py |
| Title | .s-card__title .su-styled-text.primary | search_parser.py |
| Link / item ID | a.s-card__link (/itm/{id}) | search_parser.py |
| Price | .s-card__price | search_parser.py |
| Subtitle / condition | .s-card__subtitle | search_parser.py |
| Image | img.s-card__image | search_parser.py |
| Shipping / location / format | primary attribute rows | text_utils.py |
Workflow: Open sample.html, search for stable classes like s-card__price, update parsers/selectors.py, run tests.
uv run python main.py --search "shoes men"
uv run python main.py --search "shoes men" --fresh
Args:
-search - keyword to search on eBay; visits home, then fetches that search page and parses the listings
-fresh - ignore saved cookies/session and start a new session before searching
What happens:
Load cookies from data/ebay_session.json if present
GET eBay home (warm session like a normal browser)
Wait at least 4 seconds
GET one search URL for the keyword
Parse listings into EbaySearchData
Save cookies + items back to data/ebay_session.json
If that fails: clear session, retry once, then stop
keyword → home GET → wait >= 4s → search GET → parse_ebay_search_page() → EbaySearchData
Request limits: max 1 search per process run, minimum 4s between requests, stop on captcha/challenge markers, no pagination.
uv run python -m scripts.fetch_fixture "shoes men"
# writes tests/fixtures/shoes_men_search.html and updates session store
uv run pytest -v
| Test file | What it checks |
|---|---|
| test_search_parser.py | ~60 cards from sample.html, required fields populated |
| test_url_utils.py | Item ID extraction, title cleanup, attribute classification |
| test_session_store.py | Save/load/clear cookies + items JSON |
uv run python -c "
from ebay_parse import parse_ebay_search_page
html = open('sample.html').read()
data = parse_ebay_search_page(html, keyword='shoes men')
assert len(data.items) >= 50
assert data.items[0].item_id
assert data.items[0].price
print('OK:', len(data.items), 'items |', data.items[0].title[:50])
"
pyproject.toml[project]
name = "ebay-com"
version = "0.1.0"
description = "eBay product and review scraper"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4>=4.15.0",
"httpx>=0.28.1",
"lxml>=6.1.1",
"pydantic>=2.13.4",
]
[dependency-groups]
dev = ["pyright>=1.1.410", "pytest>=9.0.3", "ruff>=0.15.16"]
ebay_parse.pyfrom models.search import EbaySearchData
from parsers.search_parser import parse_search_results
def parse_ebay_search_page(
html: str,
*,
keyword: str | None = None,
source_url: str | None = None,
) -> EbaySearchData:
return parse_search_results(html, keyword=keyword, source_url=source_url)
parsers/selectors.py (excerpt)SRP_RESULTS_LIST = "ul.srp-results"
SRP_ITEM_CARD = "li.s-card"
CARD_TITLE = ".s-card__title .su-styled-text.primary"
CARD_LINK = "a.s-card__link"
CARD_PRICE = ".s-card__price"
CARD_IMAGE = "img.s-card__image"
CARD_SUBTITLE = ".s-card__subtitle"
All selectors live in one file. When eBay changes markup, update parsers/selectors.py first - not scattered across parser modules.
Structured output lives in models/search.py:
| Model | Role |
|---|---|
| SearchResultItem | One listing card from the search river |
| EbaySearchData | Root: keyword + items |
EbaySearchData(
keyword="shoes men",
items=[
SearchResultItem(
item_id="168520328526",
title="Off White Virgil Abloh Air Jordan 1 High VAA ...",
url="<https://www.ebay.com/itm/168520328526>",
price="$999.99",
shipping="+$396.57 delivery",
condition="Pre-Owned",
location="United States",
buy_format="Buy It Now",
watchers="261 watchers",
),
# ~60 listings on sample.html
],
)
Export to JSON: data.model_dump(mode="json")
Read sample.html - search for the field that failed
Add or update the selector in parsers/selectors.py
Edit search_parser.py or text_utils.py
Run uv run pytest tests/test_search_parser.py -v
Run uv run pyright and uv run ruff check .
Repeat until tests pass and field values look correct
Do not edit the fixture during parser iteration unless eBay markup genuinely changed and you need a fresh snapshot via scripts/fetch_fixture.py.
| IssueFix | Fix |
|---|---|
| ModuleNotFoundError: constants | Run via uv run (pytest pythonpath = ["."] is configured) |
| sample.html missing | Restore sample.html, or run uv run python -m scripts.fetch_fixture "shoes men" |
| Empty / few items | Search fixture for li.s-card; update selectors.py |
| Title ends with "Opens in a new window or tab" | Use clean_listing_title() in text_utils.py |
| Live fetch looks blocked | Challenge page detected; use --fresh once, or parse local HTML with --html sample.html |
| Refusing more than 1 search | Expected - one search per run; start a new process later |
| libatomic.so.1: cannot open shared object file | sudo apt install libatomic1 |
| Tests fail after selector change | Update assertions or re-fetch fixture if markup changed |
Never fetch during parser iteration - only read sample.html
Use scraper.py for live fetches - single place for headers, delay, and session reuse
Keep models in models/ - no inline dicts in parser code
Keep selectors in parsers/selectors.py - one file to update when eBay changes markup
Prefer stable classes like s-card__* over hashed CSS
Strict typing required - pyright mode is strict
One search per live run - do not loop requests or paginate
Stop on challenge pages - do not retry in a tight loop
Your folder should look like this:
ebay/
├── index.html # This guide
├── TUTORIAL.md
├── README.md
├── sample.html # Search HTML fixture
├── api.py # Re-exports EbayScraper
├── scraper.py # Live search fetch + parse
├── session_store.py # Cookies + items cache
├── ebay_parse.py # parse_ebay_search_page(html)
├── constants.py
├── main.py # Offline / live CLI
├── pyproject.toml
├── scripts/
│ └── fetch_fixture.py # Download one search HTML fixture
├── models/
│ └── search.py # EbaySearchData, SearchResultItem
├── parsers/
│ ├── selectors.py # * Update selectors here
│ ├── text_utils.py # Title / attribute helpers
│ ├── url_utils.py # Item URL helpers
│ └── search_parser.py # Search DOM → EbaySearchData
├── data/
│ └── .gitkeep # ebay_session.json at runtime
└── tests/
├── conftest.py
├── test_search_parser.py
├── test_url_utils.py
└── test_session_store.py
You're done!
Parse local HTML with:
uv run python main.py --html sample.html --limit 10
Live search with:
uv run python main.py --search "shoes men"
Verify with:
uv run pytest
Questions? Double-check file names and paths - 95% of issues are a typo or a missing sample.html.
Katy Salgado - October 30, 2025
Why Residential IP Intelligence Services Are Highly Inaccurate?
Katy Salgado - November 13, 2025
Why Unmetered Proxies Are Cheaper (Even With a Lower Success Rate)
Katy Salgado - November 27, 2025
TCP OS Fingerprinting: How Websites Detect Automated Requests (and How Proxies Help)
Katy Salgado - December 15, 2025
Analyzing Competitor TCP Fingerprints: Do Their Opt-In Networks Really Match Their Public Claims?