实操教程2026年9月29日阅读约 9 分钟

抓取eBay商品:用 Python 做 eBay 价格监控

用 Python 抓取 eBay 商品:从公开搜索页解析标题、价格、成色和运费,监控已售价格,遇到验证页就停下,结果存进 CSV。

抓取 eBay 商品的做法是:按关键词缓慢地请求公开的搜索结果页,用几组备用选择器解析每条商品的标题、价格、成色和运费,一出现验证页就立刻停止,把结果追加写入 CSV。使用该站点所在国家的静态 IP,才能让每次运行得到的价格和运费可以互相比较。

做跨境电商的卖家常常要看 eBay 美国站、英国站、德国站上同类商品卖多少钱、运费怎么收,二手行情更要看实际成交价。本教程用 requests 和 BeautifulSoup 搭一个这样的 eBay 价格监控脚本,加上“已售价格”模式,并讲清楚该用哪条代理线路、要用多少流量。在这之前,有一件事要先说清楚。

抓取 eBay 可以吗?

eBay 的用户协议(User Agreement)规定:未经 eBay 事先明确许可,不得出于任何目的使用机器人、蜘蛛、爬虫、数据挖掘或数据提取工具,或其他自动化手段访问其服务。本文这样的脚本就在其中,即使它只读取公开的搜索页。照下面的步骤做,能让你的抓取更有礼貌,但不会让它变成被允许的事。这类条款对你是否有约束力,取决于你所在的地区和你怎么使用这些数据,一般情况见使用代理合法吗?;用于商业用途时,请咨询专业人士。

先考虑官方渠道。 eBay 开发者计划提供 Browse API,可以搜索在售商品,以 JSON 返回标题、价格、成色和运费,不用解析 HTML,页面改版也不会坏。就我们所知,已售商品数据只能通过另一个访问受限的 API 获取。如果 API 能满足你的需求,就用 API。

如果你仍然要为一个小型个人监控读取公开页面,请守住这几条:

  • 只抓公开搜索页。 不登录,也绝不要用自己的 eBay 买家或卖家账号抓取。
  • 低频率。 每天几十次搜索是监控。脚本在每两页之间等 10 到 20 秒。
  • 阅读该站点的 robots.txt,避开其中禁止访问的路径。
  • 不要用代码出价、下单或发消息。 eBay 的协议特别点名了自动下单,我们这边也不支持这种用途。

监控任何人都能看到的价格,在我们的价格监控使用场景里属于允许的一侧。

eBay 价格监控适合哪条代理线路?

eBay 显示什么,取决于你打开的是哪个站点域名,以及它认为你在哪里:运费和预计送达时间是按某个地点算的,不寄往那里的商品可能显示得不一样,或者不出现。所以监控脚本应该始终从同一个国家发出请求。

  • 先从数据中心代理开始。 下单时可以选国家、地区和城市,按 IP 算是最便宜的。先拿几个搜索词跑几天。
  • 如果放慢节奏后仍频繁出现验证页,换成 ISP 代理。 ISP IP 登记在面向家庭用户的宽带运营商名下,电商平台往往把它当作在家购物的普通用户。两者的比较见 ISP 还是数据中心代理?
  • 住宅代理不太适合。 它不能选国家,同一个搜索跑两次可能来自两个国家,显示的运费也不同。

静态 IP 可选的国家见地点页面。

用 Python 抓取eBay商品

安装依赖:

pip install requests beautifulsoup4

把下面的代码保存为 ebay_listings.py。把 EBAY 设为站点域名,搜索词填进 QUERIES,代理参数从服务页面复制。

import csv
import os
import random
import re
import time
from datetime import datetime, timezone
from urllib.parse import urlencode

import requests
from bs4 import BeautifulSoup

PROXY = "http://USERNAME:PASSWORD@HOST:PORT"
PROXIES = {"http": PROXY, "https": PROXY}
EBAY = "EBAY_DOMAIN"
QUERIES = ["QUERY_1", "QUERY_2"]
SOLD_ONLY = False
MAX_PAGES = 2
OUTPUT = "ebay_listings.csv"

HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/140.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",
}

ITEM_SELECTORS = ["li.s-card", "li.s-item"]
TITLE_SELECTORS = [".s-card__title", ".s-item__title"]
PRICE_SELECTORS = [".s-card__price", ".s-item__price"]
CONDITION_SELECTORS = [".s-card__subtitle", ".s-item__subtitle .SECONDARY_INFO"]
SHIPPING_SELECTORS = [".s-item__shipping", ".s-item__logisticsCost"]
CAPTION_SELECTORS = [".s-card__caption", ".s-item__caption--signal", ".s-item__caption"]
NOISE_SELECTORS = ".clipped, .LIGHT_HIGHLIGHT"
SHIPPING_WORDS = re.compile(r"\b(shipping|delivery|postage)\b", re.I)
ITEM_ID = re.compile(r"/itm/(?:[^/?#]+/)?(\d{9,})")
CHALLENGE_MARKERS = ("pardon our interruption", "splashui/captcha", "checking your browser")


class Blocked(Exception):
    pass


def search_url(query, page):
    params = {"_nkw": query, "_pgn": page}
    if SOLD_ONLY:
        params.update(LH_Sold=1, LH_Complete=1)
    return f"https://{EBAY}/sch/i.html?{urlencode(params)}"


def first_text(node, selectors):
    for selector in selectors:
        found = node.select_one(selector)
        if found and found.get_text(strip=True):
            return " ".join(found.get_text(" ", strip=True).split())
    return None


def shipping_text(item, title):
    text = first_text(item, SHIPPING_SELECTORS)
    if text:
        return text
    for line in item.stripped_strings:
        if SHIPPING_WORDS.search(line) and line not in (title or ""):
            return line
    return None


def low_price(text):
    match = re.search(r"\d[\d.,]*", text or "")
    if not match:
        return None
    digits = match.group().rstrip(".,")
    last = max(digits.rfind("."), digits.rfind(","))
    if last != -1 and len(digits) - last - 1 == 2:
        whole, fraction = digits[:last], digits[last + 1:]
    else:
        whole, fraction = digits, "0"
    return float(re.sub(r"[.,]", "", whole) + "." + fraction)


def parse_results(html):
    if any(marker in html.lower() for marker in CHALLENGE_MARKERS):
        raise Blocked("challenge page")
    soup = BeautifulSoup(html, "html.parser")
    for noise in soup.select(NOISE_SELECTORS):
        noise.decompose()
    items = []
    for selector in ITEM_SELECTORS:
        items = soup.select(selector)
        if items:
            break
    rows = []
    for item in items:
        link = item.select_one("a[href*='/itm/']")
        found = ITEM_ID.search(link["href"]) if link else None
        if not found:
            continue
        title = first_text(item, TITLE_SELECTORS)
        price_text = first_text(item, PRICE_SELECTORS)
        rows.append({
            "item_id": found.group(1),
            "title": title,
            "price_text": price_text,
            "price": low_price(price_text),
            "condition": first_text(item, CONDITION_SELECTORS),
            "shipping": shipping_text(item, title),
            "caption": first_text(item, CAPTION_SELECTORS),
        })
    return rows


def fetch_page(query, page):
    response = requests.get(search_url(query, page), headers=HEADERS, proxies=PROXIES, timeout=(5, 30))
    if response.status_code in (429, 503) or "/splashui/" in response.url:
        raise Blocked(f"HTTP {response.status_code} at {response.url}")
    response.raise_for_status()
    rows = parse_results(response.text)
    if not rows and page == 1:
        name = re.sub(r"\W+", "-", query)
        with open(f"unparsed-{name}.html", "w", encoding="utf-8") as f:
            f.write(response.text)
    return rows


def main():
    new_file = not os.path.exists(OUTPUT)
    seen = set()
    with open(OUTPUT, "a", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        if new_file:
            writer.writerow(["checked_at", "query", "item_id", "title", "price_text",
                             "price", "condition", "shipping", "caption"])
        for query in QUERIES:
            for page in range(1, MAX_PAGES + 1):
                try:
                    rows = fetch_page(query, page)
                except Blocked as reason:
                    print(f"Stopping at '{query}' page {page}: {reason}")
                    return
                except requests.RequestException as error:
                    print(f"'{query}' page {page}: {error}")
                    break
                checked_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
                for row in rows:
                    if row["item_id"] in seen:
                        continue
                    seen.add(row["item_id"])
                    writer.writerow([checked_at, query, row["item_id"], row["title"], row["price_text"],
                                     row["price"], row["condition"], row["shipping"], row["caption"]])
                print(f"'{query}' page {page}: {len(rows)} listings")
                time.sleep(random.uniform(10, 20))
                if not rows:
                    break


if __name__ == "__main__":
    main()

各部分的作用

  • search_url 生成普通的搜索页地址:_nkw 是搜索词,_pgn 是页码。SOLD_ONLY = True 时加上 LH_Sold=1&LH_Complete=1,和搜索筛选里的“已售商品”选项效果相同。
  • 两种布局,一个解析器。 eBay 搜索结果用过较早的 s-item 结构,近来又有 s-card 结构,两种都可能遇到。ITEM_SELECTORS 先试新的,每个字段都有一组按顺序尝试的选择器。
  • 以商品编号为准。 每条商品都链接到 /itm/ 加一长串商品编号。没有真实编号的推广位和占位条目会被跳过;同一条商品出现在两页上,只写一次。
  • 先去掉干扰文字再读。 标题元素里夹着给读屏软件看的 “Opens in a new window or tab” 和 “New listing” 标记,NOISE_SELECTORS 会先把它们删掉。
  • 运费有文字兜底。 找不到已知的运费元素时,解析器取商品里第一行提到 shipping、delivery 或 postage 的文字,并跳过标题,免得商品名里的 “delivery bag” 被当成运费。
  • 价格保留原始文字。 price_text 原样写进 CSV。price 取其中第一个数字,所以 “10.00 to 15.00” 这样的区间记录下限,1,299.00 和 1.299,00 都读成 1299。
  • 第一页为空时保存页面。 第 1 页没有结果,要么是搜索无匹配,要么是选择器不认识的布局。HTML 存到 unparsed-QUERY.html,方便你判断是哪一种。

我们通过一个带密码的本地代理,用两种布局的样例页面测试过,包括占位条目、价格区间、已售标记和欧洲数字格式。真实页面在细节上会有不同,所以有了下一节。

选择器失效时

eBay 的页面结构会不打招呼地改,上面的类名是撰写本文时在用的。某次运行开始出现空字段时,在浏览器里打开一个搜索页,右键点价格,选“检查”,把新的类名加到对应列表的最前面。花流量之前,先用保存下来的页面测试:

from ebay_listings import parse_results

with open("unparsed-QUERY_1.html", encoding="utf-8") as f:
    for row in parse_results(f.read()):
        print(row)

识别验证页,然后停下

eBay 怀疑是自动程序时,可能返回标题为 “Pardon Our Interruption” 的页面、跳转到 /splashui/ 下的验证码,或者返回 429、503。parse_results 检查前两种的标记,fetch_page 检查状态码和最终地址。任何一种都会抛出 Blocked,运行结束。

停下是有意的:验证页说明网站对这个 IP 或这种流量起了疑心,每多一个请求,疑心就多一分。等几个小时,放慢速度。如果数据中心 IP 在很温和的速度下仍不断遇到验证,就换 ISP。其他常见原因见爬虫为什么突然被封,限流见429 Too Many Requests 怎么解决。

监控 eBay 已售价格

在售价格是卖家的期望,已售价格才是买家实际付的钱,这也是大多数人做 eBay 价格监控真正想要的。设置 SOLD_ONLY = True,每行会带上类似 “Sold Sep 12, 2026” 的标记。几行代码就能把 CSV 汇总成每个搜索词的成交情况:

import csv
import statistics
from collections import defaultdict

sold = defaultdict(list)
with open("ebay_listings.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if row["price"] and (row["caption"] or "").startswith("Sold"):
            sold[row["query"]].append(float(row["price"]))

for query, prices in sold.items():
    print(f"{query}: {len(prices)} sold, median {statistics.median(prices):.2f}, "
          f"lowest {min(prices):.2f}, highest {max(prices):.2f}")

用中位数,不用平均数:一个标错的套装或一件当配件卖的坏货,就能把平均数拉偏很多。一个 CSV 只放一个站点,混着几个域名,也就混着几种货币。

eBay 抓取要用多少流量?

搜索页比看上去要重,即使不下载图片(这个脚本从不下载图片)。先通过代理量几页你自己的;按 GB 计费的代理怎么算钱里有现成的脚本。

用虚构的整数举个例子:如果一个结果页 400 KB,你每天查 20 个搜索词、每个两页,连续 30 天,就是 1,200 个页面,大约 480 MB。数据中心和 ISP IP 按 IP 租用一个周期,每种套餐包含多少流量见价格页面。

快速问答

为什么抓到的价格和我浏览器里看到的不一样? 你的浏览器带着你的位置、登录状态和 Cookie。脚本看到的是代理所在国家、未登录访客看到的内容。用无痕窗口对比。

不用代理能抓 eBay 吗? 偶尔查几个搜索词可以。从自己的地址反复运行,会更早遇到验证,而且你只能看到自己国家的运费。

要不要也抓商品详情页? 只有搜索页缺少你需要的字段时才抓。每条商品一个详情页,请求数会成倍增加,而 API 能直接返回商品详情。

从小规模开始

在该站点所在国家租一个数据中心 IP,就够对几个搜索词跑一周,看看页面是否干净地返回。价格见价格页面;如果某个站点总出问题,花更多钱之前先到 Discord 描述一下。

还有后续问题?

去 Discord 问。答案还能帮到下一个读到这个帖子的人。

加入 Discorddiscord.gg/proxypanda
$5 起步去 Discord 问