eBay Price Tracker: How to Monitor Prices Automatically

If you’ve ever tried to compare “real” eBay prices, you already know the pain:

  • listings expire and get replaced
  • prices shift due to coupons, shipping, and variants
  • the same product can have dozens of near-duplicate listings

An eBay price tracker solves this by:

  1. storing a watchlist (search URLs or item URLs)
  2. collecting listing prices on a schedule
  3. normalizing prices into a comparable schema
  4. alerting when a price drops (or rises)

This guide gives you a practical, production-shaped blueprint.

  • We’ll cover both search-based tracking (better for “market price”) and single-item tracking (better for “this exact listing”).
  • You’ll get Python code that’s designed to be extended.
  • And you’ll see where proxies (ProxiesAPI) fit when you scale.
Run scheduled price checks more reliably with ProxiesAPI

Price tracking means repeated requests over days and weeks. ProxiesAPI can reduce transient blocks and help keep your tracker stable as your watchlist grows.


What to track on eBay (choose your input)

There are two good strategies:

Example search:

  • https://www.ebay.com/sch/i.html?_nkw=ipad+mini+6

Pros:

  • you track a market (many listings)
  • less risk of one listing ending and breaking your tracker

Cons:

  • results fluctuate, you need normalization (shipping, condition)

Option B: Track a specific item URL

Example item:

  • https://www.ebay.com/itm/1234567890

Pros:

  • stable for one listing

Cons:

  • listing ends; you lose continuity

In practice: do both—search URLs for market price + item URLs for specific deals.


Architecture: the simplest tracker that works

A workable v1 pipeline:

  1. Watchlist: a table of targets (search URLs / item URLs)
  2. Fetcher: robust HTTP client (timeouts, retries, proxy toggle)
  3. Parsers:
    • parse_search_results(html) → list of listings
    • parse_item_page(html) → listing details
  4. Storage:
    • SQLite is perfect for v1
  5. Alerts:
    • email/Slack/Telegram webhook (anything)

Practical fields to store

For a search-results scrape, store a normalized listing row:

  • source: "search"
  • query_id or watch_id
  • listing_id (if you can extract)
  • title
  • price_value
  • price_currency
  • shipping_value (optional)
  • total_value (= price + shipping when possible)
  • condition (new/used/refurb)
  • url
  • scraped_at

For item pages, you can store richer details (seller, returns, etc.), but don’t overbuild.


Python: robust fetcher with optional ProxiesAPI routing

from __future__ import annotations

import os
import random
import time
from dataclasses import dataclass

import requests

TIMEOUT = (10, 30)

DEFAULT_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/123.0.0.0 Safari/537.36"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
}


@dataclass
class FetchConfig:
    use_proxiesapi: bool = True
    proxiesapi_endpoint: str | None = None
    max_retries: int = 4
    min_sleep: float = 0.8
    max_sleep: float = 1.8


class Fetcher:
    def __init__(self, cfg: FetchConfig):
        self.cfg = cfg
        self.s = requests.Session()
        self.s.headers.update(DEFAULT_HEADERS)

    def _sleep_jitter(self):
        time.sleep(random.uniform(self.cfg.min_sleep, self.cfg.max_sleep))

    def get(self, url: str) -> str:
        last_err = None
        for attempt in range(1, self.cfg.max_retries + 1):
            try:
                self._sleep_jitter()

                proxies = None
                if self.cfg.use_proxiesapi and self.cfg.proxiesapi_endpoint:
                    proxies = {"http": self.cfg.proxiesapi_endpoint, "https": self.cfg.proxiesapi_endpoint}

                r = self.s.get(url, timeout=TIMEOUT, proxies=proxies)

                if r.status_code in (403, 429, 500, 502, 503, 504):
                    raise requests.HTTPError(f"HTTP {r.status_code} for {url}", response=r)

                r.raise_for_status()
                return r.text
            except Exception as e:
                last_err = e
                time.sleep(1.2 ** attempt)

        raise RuntimeError(f"Failed after retries: {url}") from last_err


fetcher = Fetcher(FetchConfig(
    use_proxiesapi=True,
    proxiesapi_endpoint=os.getenv("PROXIESAPI_PROXY_URL"),
))

Parse eBay search results (listings)

eBay’s markup changes. Instead of betting on one brittle selector, parse by listing containers and then extract title/price/url.

A typical pattern is:

  • listing card: an element containing a link to /itm/…
  • title: text near the link
  • price: text containing a currency symbol

Here’s a pragmatic parser that works on many current layouts:

import re
from bs4 import BeautifulSoup


def parse_money(text: str) -> tuple[float | None, str | None]:
    # Handles "$12.34" or "US $12.34"
    if not text:
        return None, None
    currency = "USD" if "$" in text else None
    m = re.search(r"(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)", text)
    if not m:
        return None, currency
    value = float(m.group(1).replace(",", ""))
    return value, currency


def parse_search_results(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")

    rows: list[dict] = []

    # Find anchors that look like listing links
    for a in soup.select('a[href*="/itm/"]'):
        href = a.get("href")
        if not href:
            continue

        url = href.split("?")[0]
        title = a.get_text(" ", strip=True) or None
        if not title or len(title) < 4:
            continue

        card = a.find_parent(["li", "div"])  # climb to container
        price_text = None
        if card:
            price_el = card.select_one("span.s-item__price, span[class*='price']")
            price_text = price_el.get_text(" ", strip=True) if price_el else None

        price_value, currency = parse_money(price_text or "")

        rows.append({
            "title": title,
            "url": url,
            "price_value": price_value,
            "price_currency": currency,
        })

    # de-dupe by URL
    dedup = {}
    for r in rows:
        dedup[r["url"]] = r

    return list(dedup.values())

Pagination

Search results typically include a &_pgn= parameter (page number). You can either:

  • generate pages by adding &_pgn=2, &_pgn=3, …
  • or parse the “next page” link.

For a tracker, generating &_pgn= is usually simplest.


Crawl a watchlist entry (search URL)

from urllib.parse import urlparse, parse_qs, urlencode, urlunparse


def set_query_param(url: str, key: str, value: str) -> str:
    u = urlparse(url)
    q = parse_qs(u.query)
    q[key] = [value]
    new_query = urlencode(q, doseq=True)
    return urlunparse((u.scheme, u.netloc, u.path, u.params, new_query, u.fragment))


def crawl_search(url: str, pages: int = 2) -> list[dict]:
    out: list[dict] = []
    for p in range(1, pages + 1):
        page_url = set_query_param(url, "_pgn", str(p))
        html = fetcher.get(page_url)
        batch = parse_search_results(html)
        out.extend(batch)
        print("page", p, "batch", len(batch), "total", len(out))
    return out


watch_url = "https://www.ebay.com/sch/i.html?_nkw=ipad+mini+6"
listings = crawl_search(watch_url, pages=2)
print(listings[:3])

Store results in SQLite (v1)

import sqlite3
from datetime import datetime, timezone


def init_db(conn: sqlite3.Connection):
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS prices (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            watch_url TEXT NOT NULL,
            listing_url TEXT NOT NULL,
            title TEXT,
            price_value REAL,
            price_currency TEXT,
            scraped_at TEXT NOT NULL
        );
        """
    )
    conn.execute("CREATE INDEX IF NOT EXISTS idx_prices_watch ON prices(watch_url);")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_prices_listing ON prices(listing_url);")


def insert_prices(conn: sqlite3.Connection, watch_url: str, rows: list[dict]):
    now = datetime.now(timezone.utc).isoformat()
    conn.executemany(
        """
        INSERT INTO prices (watch_url, listing_url, title, price_value, price_currency, scraped_at)
        VALUES (?, ?, ?, ?, ?, ?)
        """,
        [
            (
                watch_url,
                r.get("url"),
                r.get("title"),
                r.get("price_value"),
                r.get("price_currency"),
                now,
            )
            for r in rows
            if r.get("url")
        ],
    )
    conn.commit()


conn = sqlite3.connect("ebay_tracker.sqlite")
init_db(conn)
insert_prices(conn, watch_url, listings)
print("inserted", len(listings))

Alerting: detect price drops

In practice you’ll alert on a normalized metric like “median price of the first page” or “lowest total price.”

Here’s a simple “lowest seen in last run” approach:


def lowest_price(rows: list[dict]) -> float | None:
    vals = [r["price_value"] for r in rows if isinstance(r.get("price_value"), (int, float))]
    return min(vals) if vals else None

current_low = lowest_price(listings)
print("current low", current_low)

Then send an alert if current_low crosses your threshold.


Comparison: scraping vs. APIs vs. headless browsers

A quick decision table:

  • HTML scraping (requests + parsing)

    • Best when: you want low cost, simple deployment
    • Risk: markup changes; you may get blocked at scale
  • Headless browser (Playwright/Selenium)

    • Best when: content is JS-rendered or gated by interactions
    • Tradeoff: heavier infra, slower, more detectable
  • Official APIs / partners

    • Best when: you need long-term stability, compliance, and scale
    • Tradeoff: cost and access limits

Where ProxiesAPI helps (honestly)

Price tracking is not one scrape. It’s thousands of repeated requests over time.

As your watchlist grows, you’ll see:

  • intermittent 403/429
  • runs that fail halfway through
  • variability by region

ProxiesAPI helps by giving you a configurable proxy layer that you can enable for scheduled jobs so your tracker fails less often and needs less manual intervention.

Run scheduled price checks more reliably with ProxiesAPI

Price tracking means repeated requests over days and weeks. ProxiesAPI can reduce transient blocks and help keep your tracker stable as your watchlist grows.

Related guides

eBay Price Tracker: How to Monitor Prices Automatically (Alerts, History, and Data Model)
A practical blueprint for tracking eBay prices at scale: what to scrape, how to normalize variants, and how to store history for alerts and dashboards.
guide#ebay price tracker#ebay#price-tracking
Web Scraping Dynamic Content: How to Handle JavaScript-Rendered Pages
Decision tree for JS sites: XHR capture, HTML endpoints, or headless—plus when proxies matter.
guide#web-scraping#javascript#dynamic-content
Web Scraping Tools: The 2026 Buyer’s Guide (What to Use When)
A practical 2026 buyer’s guide to web scraping tools: no-code extractors, browser automation, scraping frameworks, and hosted APIs — plus how proxies fit into a reliable stack.
guide#web-scraping#scraping-tools#browser-automation
How to Scrape Data Without Getting Blocked: A Practical Playbook
A no-fluff anti-blocking guide: rate limits, fingerprints, retries/backoff, header hygiene, caching, and when proxy rotation (ProxiesAPI) is the simplest fix. Includes comparison tables and checklists.
guide#web-scraping#anti-block#proxies