抓取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 描述一下。