- 为什么抓二手车成交数据
二手车是典型的非标品:同一款车,因为上牌年份、行驶里程、所在城市、车况等级不同,价格能差出好几万。汽车之家二手车(che168.com)聚合了全国大量真实成交记录,是做残值率分析、定价模型、区域行情监控最省钱的公开数据源。
我们自己要做定价参考时,最关心的两个字段就是:
- 成交价格(dealPrice):车主实际卖出的价格,比挂牌价更有参考价值;
- 行驶里程(mileage):衡量车辆损耗的核心指标,通常和价格呈强负相关。
把这两个字段加上车型、城市、上牌时间一起抓下来,就能画出「某车型 / 某市 / 某年款」的价格---里程曲线。举个实际场景:你想买一辆三年的丰田凯美瑞,挂牌价从 11 万到 14 万都有,到底哪个算捡漏?把本地近半年的真实成交记录拉出来,按里程分组算中位数,再对照目标车的里程,一眼就知道报价合不合理。这种「用真实成交价给挂牌价做基准」的用法,比任何估值工具都更贴地气。
更重要的是,成交数据是时间序列的------同一款车这个月成交价 12.3 万,下个月 11.8 万,背后可能是新车降价、排放标准切换或季节因素影响。持续抓取、积累历史,你手里就有了一张动态行情表,而不是某个时间点的快照。这正是爬虫相比「手动查一次」最大的价值:它把一次性动作变成了可复用的数据流。
2. 接口分析:别爬 HTML,抓 JSON 接口
直接用 requests 把成交列表页拉下来再解析 HTML 是最笨的做法------页面结构一改就全废,而且接口字段往往藏得深。正确姿势是监听接口:
- 打开 che168.com 二手车成交页,按 F12 进 DevTools;
- 切到 Network 面板,勾选
XHR / Fetch; - 刷新页面,找返回
application/json的请求,通常带getList、chengjiao、dealList之类的关键字; - 看它的 Query 参数(page、pageSize、brandId、cityId、seriesId)和 Response 结构(一个
data.list数组)。
拿到真实接口后,直接请求接口比解析 DOM 稳定一个数量级。下面假设我们定位到这样一个接口(实际路径以你抓到为准):
plain
GET https://www.che168.com/front/api/usedcar/dealList
?page=1&pageSize=20&cityId=110100&seriesId=0
几个调试要点,能省你很多坑:
- 先看 Preview 再写代码 :DevTools 里点开 Response 的 Preview,确认
data.list里真的有dealPrice、mileage这些字段,以及分页是否靠page还是pageIndex控制; - Cookie 可能不是必须的 :很多列表接口是公开匿名接口,但带上一两个关键 Cookie(如
fvlid、sessionid)会更像真人,降低被拦概率; - 参数别多带:只保留翻页和筛选相关的参数,多余的参数可能是签名校验来源,少带反而更干净;
- 注意 gzip :
requests默认会自动解压,但如果自己用httpx或aiohttp,记得开启follow_redirects并处理好压缩,否则拿到的是乱码。
3. 最小可运行爬虫
先写一个不带代理的版本,确认接口能通:
python
import time
import requests
BASE_URL = "https://www.che168.com/front/api/usedcar/dealList"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36"
),
"Referer": "https://www.che168.com/",
"Accept": "application/json, text/plain, */*",
}
def fetch_deals(city_id: int, max_pages: int = 5) -> list[dict]:
"""抓取指定城市的二手车成交记录。
Args:
city_id: 城市编码,如北京 110100。
max_pages: 最多翻页数,防止无限爬取。
Returns:
成交记录字典列表,每条含车型/价格/里程等字段。
"""
results: list[dict] = []
for page in range(1, max_pages + 1):
params = {"page": page, "pageSize": 20, "cityId": city_id}
resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=10)
resp.raise_for_status()
payload = resp.json()
items = payload.get("data", {}).get("list", [])
if not items:
break
results.extend(items)
print(f"page={page} got {len(items)} items")
time.sleep(1.5) # 礼貌性限速,别把人家打挂
return results
if __name__ == "__main__":
deals = fetch_deals(city_id=110100)
print(f"total: {len(deals)}")
字段映射时留意:成交价通常在 dealPrice 或 price 里(单位元),里程在 mileage(单位万公里)或 licheng,上牌时间在 registerDate / cardDate。以你实际抓到的 JSON 为准,不要照抄字段名。
4. 接入云代理 IP
单机直连可用于小规模采集,但批量抓取全国数据时,目标站点会基于出口 IP 做限流或封禁,引入代理 IP 是必要手段。
本文采用亿牛云(16yun.cn)动态转发代理 :所有请求统一经其转发网关出口,由后台按请求轮换出口 IP,调用方无需自建与维护 IP 池。接入方式即在 requests 的 proxies 中配置转发网关,认证凭据为订单号(用户名)与订单密码,用户名可按 -地区-ttl 追加出口地域与存活时长参数:
python
# 亿牛云动态转发代理(16yun.cn)
# 统一转发网关,后台按请求轮换出口 IP,无需自建 IP 池
PROXY_HOST = "t.16yun.cn"
PROXY_PORT = "31111"
PROXY_USER = "YOUR_ORDER_ID" # 亿牛云订单号
PROXY_PASS = "YOUR_PASSWORD" # 订单密码
PROXIES = {
"http": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
"https": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
}
在请求中挂载代理:
python
resp = requests.get(
BASE_URL, params=params, headers=HEADERS,
proxies=PROXIES, timeout=10,
)
工程上建议将代理请求封装为 get_with_proxy(),内部对超时/连接异常做有限次重试(如 3 次),以吸收单点代理抖动。亿牛云另提供 API 提取模式(主动拉取 IP 池自行调度),适用于需要精细控制并发与地域分布的场景;动态转发模式运维成本更低,更契合本文的中等规模采集。
5. 数据清洗与落库
抓回来的原始 JSON 还不能直接分析。里程可能是 "3.2万公里" 这种字符串,价格可能带单位。用 Pandas 统一清洗:
python
import pandas as pd
import re
def clean_deals(raw: list[dict]) -> pd.DataFrame:
"""把原始成交记录清洗成规整的 DataFrame。
Args:
raw: 接口返回的原始记录列表。
Returns:
规整后的 DataFrame,价格/里程均为 float。
"""
rows = []
for it in raw:
price = it.get("dealPrice") or it.get("price")
mileage = it.get("mileage") or it.get("licheng")
rows.append({
"model": it.get("seriesName") or it.get("carName"),
"city": it.get("cityName"),
"register_date": it.get("registerDate"),
"deal_price": float(re.sub(r"[^\d.]", "", str(price))) if price else None,
"mileage": float(re.sub(r"[^\d.]", "", str(mileage))) if mileage else None,
})
df = pd.DataFrame(rows).drop_duplicates()
return df
if __name__ == "__main__":
deals = fetch_deals(city_id=110100)
df = clean_deals(deals)
df.to_csv("used_car_deals.csv", index=False, encoding="utf-8-sig")
print(df.describe()) # 快速看价格/里程分布
encoding="utf-8-sig" 是为了让 Excel 打开中文不乱码。如果需要长期追踪价格变动,把 df 换成 upsert 写入 MySQL(用唯一键 车型+城市+上牌时间)即可建立历史价格表。
6. 拿到数据能做什么:价格---里程相关性分析
数据落库后,最有代表性的一个分析就是看「里程越高、价格越低」这件事到底有多强。用 matplotlib 画个散点图加趋势线:
python
import matplotlib.pyplot as plt
import numpy as np
def plot_price_vs_mileage(df: pd.DataFrame, model: str) -> None:
"""绘制指定车型的价格---里程散点图与趋势线。
Args:
df: 清洗后的成交 DataFrame。
model: 车型名,用于筛选与标题。
"""
sub = df[df["model"] == model].dropna(subset=["deal_price", "mileage"])
if len(sub) < 5:
print(f"{model} 样本不足,跳过绘图")
return
plt.figure(figsize=(8, 5))
plt.scatter(sub["mileage"], sub["deal_price"], alpha=0.6)
# 一元线性回归趋势线
z = np.polyfit(sub["mileage"], sub["deal_price"], 1)
x = np.linspace(sub["mileage"].min(), sub["mileage"].max(), 100)
plt.plot(x, np.polyval(z, x), color="red", label=f"趋势线 斜率={z[0]:.2f}万/万公里")
plt.xlabel("里程(万公里)")
plt.ylabel("成交价(万元)")
plt.title(f"{model} 价格---里程关系")
plt.legend()
plt.savefig(f"{model}_price_mileage.png", dpi=120)
plt.close()
# 调用示例
# plot_price_vs_mileage(df, "凯美瑞")
趋势线的斜率直接告诉你「每多跑 1 万公里,这车大概掉多少价」。不同车型斜率差异很大------日系保值车斜率平缓,冷门车斜率陡峭。这种结论,没有真实成交数据根本算不出来。
7. 进阶:多城市并发采集
单城市爬完,自然想横向对比。用 concurrent.futures.ThreadPoolExecutor 把多个城市的请求并行起来,配亿牛云代理基本不会触发单 IP 限流:
python
from concurrent.futures import ThreadPoolExecutor
CITY_IDS = [110100, 310100, 440100, 510100] # 北京/上海/广州/成都
def crawl_city(city_id: int) -> pd.DataFrame:
"""采集单个城市并清洗,返回 DataFrame。"""
return clean_deals(fetch_deals(city_id, max_pages=10))
with ThreadPoolExecutor(max_workers=4) as pool:
frames = list(pool.map(crawl_city, CITY_IDS))
df_all = pd.concat(frames, ignore_index=True)
df_all.to_csv("used_car_deals_multi.csv", index=False, encoding="utf-8-sig")
并发数别贪多,max_workers 控制在 4~8 之间,配合每请求 1 秒左右的限速,对目标站点足够友好,也足够把你一天的量在几分钟内跑完。
8. 反爬与工程化注意事项
- 限速优先 :
time.sleep别省,配合亿牛云轮换 IP,单机也能稳稳爬几千条; - Header 完整 :
Referer、User-Agent必须带,Accept标明 json,否则接口可能返回 403; - UA 轮换:准备一个 UA 池随机挑,避免单个 UA 特征被识别;
- 签名/Token :部分接口会校验
sign或token,需要逆向 JS 还原算法(这是进阶关卡,本文先不展开); - 字体反爬:极少数页面用自定义字体混淆数字,需要下载字体文件做映射还原;
- 异常兜底 :网络超时、JSON 解析失败都要
try/except包住,单条失败不影响整体; - 合规边界 :仅抓取公开数据用于个人分析,控制频率,遵守
robots.txt,不碰任何隐私字段。
额外提醒一个工程化细节:断点续爬 。批量采集经常因为网络抖动中断,建议在落盘时按 city_id + page 记录进度,重启后跳过已抓的页,而不是从头再来。可以用一个本地 set 或 SQLite 小表存「已完成的 page 键」,每次抓取前先查一下。
还有一个容易被忽略的数据质量问题:成交记录里偶尔会出现「价格 0」「里程 0」或明显离谱的异常值(比如标价误填成成交价)。清洗阶段一定要加一道规则过滤------比如 deal_price 低于 0.5 万或高于 500 万的直接丢弃,mileage 超过 50 万公里的视为脏数据。宁可少几条,也别让一个离群点把整条趋势线带歪。
9. 小结
这条链路的核心就三步:抓接口不抓页面 → 用亿牛云代理绕开限流 → Pandas 清洗落库。接口逆向让爬虫更稳,动态代理让爬虫更久,清洗落库让数据真正可用。把成交价和里程抓全之后,你就能做任何想做的分析了------比如算某款车的「每万公里贬值曲线」,或者对比一线城市和二线城市的同车价差。