
二手车是典型的非标品:同一款车,因为上牌年份、行驶里程、所在城市、车况等级不同,价格能差出好几万。汽车之家二手车(che168.com)聚合了全国大量真实成交记录,是做残值率分析、定价模型、区域行情监控最省钱的公开数据源。
我们自己要做定价参考时,最关心的两个字段就是:
把这两个字段加上车型、城市、上牌时间一起抓下来,就能画出「某车型 / 某市 / 某年款」的价格—里程曲线。举个实际场景:你想买一辆三年的丰田凯美瑞,挂牌价从 11 万到 14 万都有,到底哪个算捡漏?把本地近半年的真实成交记录拉出来,按里程分组算中位数,再对照目标车的里程,一眼就知道报价合不合理。这种「用真实成交价给挂牌价做基准」的用法,比任何估值工具都更贴地气。
更重要的是,成交数据是时间序列的——同一款车这个月成交价 12.3 万,下个月 11.8 万,背后可能是新车降价、排放标准切换或季节因素影响。持续抓取、积累历史,你手里就有了一张动态行情表,而不是某个时间点的快照。这正是爬虫相比「手动查一次」最大的价值:它把一次性动作变成了可复用的数据流。
直接用 requests 把成交列表页拉下来再解析 HTML 是最笨的做法——页面结构一改就全废,而且接口字段往往藏得深。正确姿势是监听接口:
XHR / Fetch;application/json 的请求,通常带 getList、chengjiao、dealList 之类的关键字;data.list 数组)。拿到真实接口后,直接请求接口比解析 DOM 稳定一个数量级。下面假设我们定位到这样一个接口(实际路径以你抓到为准):
GET https://www.che168.com/front/api/usedcar/dealList
?page=1&pageSize=20&cityId=110100&seriesId=0几个调试要点,能省你很多坑:
data.list 里真的有 dealPrice、mileage 这些字段,以及分页是否靠 page 还是 pageIndex 控制;fvlid、sessionid)会更像真人,降低被拦概率;requests 默认会自动解压,但如果自己用 httpx 或 aiohttp,记得开启 follow_redirects 并处理好压缩,否则拿到的是乱码。先写一个不带代理的版本,确认接口能通:
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 为准,不要照抄字段名。
单机直连可用于小规模采集,但批量抓取全国数据时,目标站点会基于出口 IP 做限流或封禁,引入代理 IP 是必要手段。
本文采用亿牛云(16yun.cn)动态转发代理:所有请求统一经其转发网关出口,由后台按请求轮换出口 IP,调用方无需自建与维护 IP 池。接入方式即在 requests 的 proxies 中配置转发网关,认证凭据为订单号(用户名)与订单密码,用户名可按 -地区-ttl 追加出口地域与存活时长参数:
# 亿牛云动态转发代理(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}",
}在请求中挂载代理:
resp = requests.get(
BASE_URL, params=params, headers=HEADERS,
proxies=PROXIES, timeout=10,
)工程上建议将代理请求封装为 get_with_proxy(),内部对超时/连接异常做有限次重试(如 3 次),以吸收单点代理抖动。亿牛云另提供 API 提取模式(主动拉取 IP 池自行调度),适用于需要精细控制并发与地域分布的场景;动态转发模式运维成本更低,更契合本文的中等规模采集。
抓回来的原始 JSON 还不能直接分析。里程可能是 "3.2万公里" 这种字符串,价格可能带单位。用 Pandas 统一清洗:
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(用唯一键 车型+城市+上牌时间)即可建立历史价格表。
数据落库后,最有代表性的一个分析就是看「里程越高、价格越低」这件事到底有多强。用 matplotlib 画个散点图加趋势线:
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 万公里,这车大概掉多少价」。不同车型斜率差异很大——日系保值车斜率平缓,冷门车斜率陡峭。这种结论,没有真实成交数据根本算不出来。
单城市爬完,自然想横向对比。用 concurrent.futures.ThreadPoolExecutor 把多个城市的请求并行起来,配亿牛云代理基本不会触发单 IP 限流:
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 秒左右的限速,对目标站点足够友好,也足够把你一天的量在几分钟内跑完。
time.sleep 别省,配合亿牛云轮换 IP,单机也能稳稳爬几千条;Referer、User-Agent 必须带,Accept 标明 json,否则接口可能返回 403;sign 或 token,需要逆向 JS 还原算法(这是进阶关卡,本文先不展开);try/except 包住,单条失败不影响整体;robots.txt,不碰任何隐私字段。额外提醒一个工程化细节:断点续爬。批量采集经常因为网络抖动中断,建议在落盘时按 city_id + page 记录进度,重启后跳过已抓的页,而不是从头再来。可以用一个本地 set 或 SQLite 小表存「已完成的 page 键」,每次抓取前先查一下。
还有一个容易被忽略的数据质量问题:成交记录里偶尔会出现「价格 0」「里程 0」或明显离谱的异常值(比如标价误填成成交价)。清洗阶段一定要加一道规则过滤——比如 deal_price 低于 0.5 万或高于 500 万的直接丢弃,mileage 超过 50 万公里的视为脏数据。宁可少几条,也别让一个离群点把整条趋势线带歪。
这条链路的核心就三步:抓接口不抓页面 → 用亿牛云代理绕开限流 → Pandas 清洗落库。接口逆向让爬虫更稳,动态代理让爬虫更久,清洗落库让数据真正可用。把成交价和里程抓全之后,你就能做任何想做的分析了——比如算某款车的「每万公里贬值曲线」,或者对比一线城市和二线城市的同车价差。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。