Scraping eBay search results gives you market-level pricing data that a single product page can't: you see dozens of competing listings at once, complete with condition grades, shipping costs, and seller ratings. That makes eBay one of the best targets for price benchmarking, resale arbitrage research, and competitive inventory monitoring.
The catch is that eBay's results pages are protected by bot detection: when we checked in October 2026, a plain HTTP request to a search URL came back as a 403. The samples below route requests through the ZenRows Fetch API (formerly the Universal Scraper API), which handles rendering and unblocking for you, and then parse the returned HTML with standard libraries.
Updated October 2026: eBay has replaced its
s-itemresult markup withs-cardcards, which broke most older eBay scrapers. The selectors and code below match the current page and were run against a live results page on 30 October 2026.
Prerequisites
Sign up for a ZenRows account and set your API key:
export ZENROWS_API_KEY="your_api_key_here"
Each sample accepts a search query as a command-line argument and outputs a JSON array of listings. Each object contains the title, price string, condition label, shipping cost, and canonical listing URL (tracking parameters stripped).
Understanding eBay Search HTML
eBay renders each result as an <li class="s-card"> directly inside <ul class="srp-results">. The useful sub-elements are:
| Field | CSS Selector |
|---|---|
| Listing card | ul.srp-results > li.s-card |
| Title | .s-card__title .su-styled-text |
| Price | every .s-card__price in the card, joined |
| Condition | .s-card__subtitle |
| Shipping | the .s-card__attribute-row whose text mentions delivery or shipping |
| Listing URL | a.s-card__link (href attribute) |
A few details matter:
- Title.
.s-card__titlealso holds a hidden "Opens in a new window or tab" label for screen readers. Selecting the.su-styled-textspan inside it gives the clean title. - Price. A listing with variations shows a range split across three elements,
$24.77,toand$55.03. Joining every.s-card__pricein the card gives$24.77 to $55.03. A struck-through old price sits in the same row without that class, so it's skipped. - Shipping. It has no class of its own. It's one of several attribute rows, with text such as
+$6.43 deliveryorFree International Shipping. - Placeholders. eBay also renders "Shop on eBay" placeholder cards. They sit outside the direct children of
ul.srp-results, and the samples skip that title anyway.
PHP
<?php
// Run: php ebay.php "mechanical keyboard"
$apiKey = getenv('ZENROWS_API_KEY');
$query = $argv[1] ?? 'mechanical keyboard';
$targetUrl = 'https://www.ebay.com/sch/i.html?' . http_build_query([
'_nkw' => $query,
'_sop' => 12, // sort by best match
]);
$apiUrl = 'https://api.zenrows.com/v1/?' . http_build_query([
'apikey' => $apiKey,
'url' => $targetUrl,
'js_render' => 'true',
]);
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$html = curl_exec($ch);
if ($html === false) {
fwrite(STDERR, 'Request failed: ' . curl_error($ch) . PHP_EOL);
exit(1);
}
curl_close($ch);
$doc = new DOMDocument();
@$doc->loadHTML($html);
$xp = new DOMXPath($doc);
$cls = fn(string $c): string => "contains(concat(' ', normalize-space(@class), ' '), ' $c ')";
$text = fn(DOMNode $ctx, string $q): string =>
trim($xp->query($q, $ctx)->item(0)?->textContent ?? '');
$items = [];
$cards = $xp->query("//ul[{$cls('srp-results')}]/li[{$cls('s-card')}]");
foreach ($cards as $li) {
$title = $text($li, ".//*[{$cls('s-card__title')}]//span[{$cls('su-styled-text')}]");
if ($title === '' || $title === 'Shop on eBay') continue;
// A price range is split across several .s-card__price elements
$price = '';
foreach ($xp->query(".//*[{$cls('s-card__price')}]", $li) as $p) {
$price .= $p->textContent;
}
// Shipping has no class of its own: find the attribute row that mentions it
$shipping = '';
foreach ($xp->query(".//*[{$cls('s-card__attribute-row')}]", $li) as $row) {
if (preg_match('/delivery|shipping/i', $row->textContent)) {
$shipping = trim($row->textContent);
break;
}
}
$href = $xp->query(".//a[{$cls('s-card__link')}]", $li)->item(0)?->getAttribute('href') ?? '';
$items[] = [
'title' => $title,
'price' => trim($price),
'condition' => $text($li, ".//*[{$cls('s-card__subtitle')}]"),
'shipping' => $shipping,
'url' => strtok($href, '?'),
];
}
echo json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PHP_EOL;
Node.js
// ebay.mjs — node ebay.mjs "mechanical keyboard"
// Install: npm i axios cheerio
import axios from 'axios';
import * as cheerio from 'cheerio';
const apiKey = process.env.ZENROWS_API_KEY;
const query = process.argv[2] ?? 'mechanical keyboard';
const targetUrl =
`https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(query)}&_sop=12`;
const { data: html } = await axios.get('https://api.zenrows.com/v1/', {
params: { apikey: apiKey, url: targetUrl, js_render: 'true' },
timeout: 60_000,
});
const $ = cheerio.load(html);
const items = [];
$('ul.srp-results > li.s-card').each((_, el) => {
const card = $(el);
const title = card.find('.s-card__title .su-styled-text').first().text().trim();
if (!title || title === 'Shop on eBay') return;
const rows = card.find('.s-card__attribute-row').map((_, r) => $(r).text().trim()).get();
items.push({
title,
price: card.find('.s-card__price').text().trim(), // joins "$24.77", " to ", "$55.03"
condition: card.find('.s-card__subtitle').first().text().trim(),
shipping: rows.find((t) => /delivery|shipping/i.test(t)) ?? '',
url: card.find('a.s-card__link').attr('href')?.split('?')[0] ?? '',
});
});
console.log(JSON.stringify(items, null, 2));
Rust
// Cargo.toml:
// reqwest = { version = "0.12", features = ["blocking"] }
// scraper = "0.20"
// serde_json = "1"
// urlencoding = "2"
use scraper::{ElementRef, Html, Selector};
use serde_json::{json, Value};
fn sel(s: &str) -> Selector { Selector::parse(s).unwrap() }
fn text_of(el: ElementRef) -> String {
el.text().collect::<String>().trim().to_string()
}
fn first_text(root: &ElementRef, selector: &Selector) -> String {
root.select(selector).next().map(text_of).unwrap_or_default()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("ZENROWS_API_KEY")?;
let query = std::env::args().nth(1).unwrap_or_else(|| "mechanical keyboard".into());
let target = format!(
"https://www.ebay.com/sch/i.html?_nkw={}&_sop=12",
urlencoding::encode(&query)
);
let html = reqwest::blocking::Client::new()
.get("https://api.zenrows.com/v1/")
.query(&[
("apikey", api_key.as_str()),
("url", target.as_str()),
("js_render", "true"),
])
.timeout(std::time::Duration::from_secs(60))
.send()?
.text()?;
let doc = Html::parse_document(&html);
let card_sel = sel("ul.srp-results > li.s-card");
let title_sel = sel(".s-card__title .su-styled-text");
let price_sel = sel(".s-card__price");
let cond_sel = sel(".s-card__subtitle");
let row_sel = sel(".s-card__attribute-row");
let link_sel = sel("a.s-card__link");
let mut items: Vec<Value> = Vec::new();
for card in doc.select(&card_sel) {
let title = first_text(&card, &title_sel);
if title.is_empty() || title == "Shop on eBay" {
continue;
}
// A price range is split across several .s-card__price elements
let price: String = card.select(&price_sel)
.map(|e| e.text().collect::<String>())
.collect::<String>()
.trim()
.to_string();
let shipping = card.select(&row_sel)
.map(text_of)
.find(|t| {
let t = t.to_lowercase();
t.contains("delivery") || t.contains("shipping")
})
.unwrap_or_default();
let url = card.select(&link_sel)
.next()
.and_then(|a| a.value().attr("href"))
.map(|h| h.split('?').next().unwrap_or(h).to_string())
.unwrap_or_default();
items.push(json!({
"title": title,
"price": price,
"condition": first_text(&card, &cond_sel),
"shipping": shipping,
"url": url,
}));
}
println!("{}", serde_json::to_string_pretty(&items)?);
Ok(())
}
Paginating Through Results
A results page holds 60 listings by default. Add _ipg=120 or _ipg=240 for larger pages, the two sizes eBay's own page offers, and use _pgn to move through them. Increment _pgn from 1 upward and stop when the results list is empty:
https://www.ebay.com/sch/i.html?_nkw=mechanical+keyboard&_sop=12&_ipg=240&_pgn=2
Fewer, larger pages mean fewer requests, which costs less through any scraping API and puts less load on eBay.
Add a short pause between requests (one to two seconds) to stay within polite crawl rates.
Sold Listings Now Need a Login
Sold prices used to be the best baseline for market value, and the usual trick was adding &LH_Complete=1&LH_Sold=1 to a search URL. When we checked on 30 October 2026, those searches redirected to eBay's sign-in page instead of returning results.
Scraping pages behind a login is outside the scope of this guide, and it brings account and terms-of-service risks that public pages don't. If you need sold-price data, look at eBay's own developer APIs first. Otherwise, track active listings over time: a listing that disappears between runs has usually sold or ended, and its last asking price is a reasonable signal.
Building a Price Monitor
Turn this scraper into a lightweight price monitor:
- Run on a schedule — use a cron job or task queue. Daily is fine for slow-moving categories; hourly suits fast-moving ones like consumer electronics.
- Store price history — write
{query, title, price, timestamp}to a database or CSV on each run. - Alert on drops — compare the latest price against a rolling minimum and fire a notification when a new low appears.
The Amazon Product Tracking and Walmart Product Tracking guides use the same store-and-compare pattern with different parsers. To track a single eBay listing by item ID (rather than search results), see eBay Product Tracking.
Notes
- Selector drift — eBay's markup changes with redesigns; the move from
s-itemtos-cardis the latest example. If results come back empty, open the live page in DevTools, inspect a listing card, and update the selectors. Check that your scraper returns a sensible number of items on every run, so a silent zero doesn't go unnoticed. - Price strings — prices can be ranges (
$24.77 to $55.03for listings with variations), and "or Best Offer" appears in a separate row. Store the raw string and parse it downstream, or use a regex to extract the lower bound. - Blocks — if ZenRows returns a block page instead of results, add
premium_proxy=true, which routes the request through residential IPs. Per ZenRows' docs, it costs 10 credits a request, or 25 withjs_render. - Alternative unblocking services — if you need higher throughput or different billing models, Bright Data's Web Unlocker and Oxylabs are strong alternatives. See our ZenRows vs. ScraperAPI comparison and the broader proxy and scraping tool comparisons for a side-by-side view.
See also: E-commerce Web Scraping Solutions, ZenRows review, and How to Avoid Getting Blocked.
FAQ
Why does my old eBay scraper return an empty list?
eBay replaced its search result markup. Listings used to be li.s-item elements; they are now li.s-card cards inside ul.srp-results, with new class names for the title, price, condition and links. Scrapers written against the s-item selectors find nothing. The samples on this page use the current s-card selectors.
How many results does an eBay search page return?
60 listings by default. The page offers 120 and 240 per page through the _ipg parameter, and _pgn moves through the pages.
Can I scrape eBay sold listings?
Not anonymously any more. When we checked in October 2026, searches with the sold and completed filters (LH_Sold=1 and LH_Complete=1) redirected to eBay's sign-in page. Scraping behind a login is outside this guide; use active listings, or eBay's own developer APIs if you need sold data.
Do I need JavaScript rendering to scrape eBay search?
The samples turn it on to be safe. More important is getting past bot detection: a plain HTTP request to eBay search returned 403 when we checked. If ZenRows still returns a block page, add premium_proxy, which routes through residential IPs.