Python 爬虫实战:抓取汽车之家二手车成交价格与里程数据
## 1.为什么抓二手车成交数据 二手车是典型的非标品:同一款车,因为上牌年份、行驶里程、所在城市、车况等级不同,价格能差出好几万。汽车之家二手车(che168.com)聚合了全国大量真实成交记录,是做**残值率分析、定价模型、区域行情监控**最省钱的公开数据源。 我们自己要做定价参考时,最关心的两个字段就是: - **成交价格(dealPrice)**:车主实际卖出的价格,比挂牌价更有参考价值; - **行驶里程(mileage)**:衡量车辆损耗的核心指标,通常和价格呈强负相关。 把这两个字段加上车型、城市、上牌时间一起抓下来,就能画出「某车型 / 某市 / 某年款」的价格—里程曲线。举个实际场景:你想买一辆三年的丰田凯美瑞,挂牌价从 11 万到 14 万都有,到底哪个算捡漏?把本地近半年的真实成交记录拉出来,按里程分组算中位数,再对照目标车的里程,一眼就知道报价合不合理。这种「用真实成交价给挂牌价做基准」的用法,比任何估值工具都更贴地气。 更重要的是,成交数据是**时间序列**的——同一款车这个月成交价 12.3 万,下个月 11.8 万,背后可能是新车降价、排放标准切换或季节因素影响。持续抓取、积累历史,你手里就有了一张动态行情表,而不是某个时间点的快照。这正是爬虫相比「手动查一次」最大的价值:它把一次性动作变成了可复用的数据流。 ## 2. 接口分析:别爬 HTML,抓 JSON 接口 直接用 `requests` 把成交列表页拉下来再解析 HTML 是最笨的做法——页面结构一改就全废,而且接口字段往往藏得深。正确姿势是**监听接口**: 1. 打开 che168.com 二手车成交页,按 F12 进 DevTools; 1. 切到 Network 面板,勾选 `XHR / Fetch`; 1. 刷新页面,找返回 `application/json` 的请求,通常带 `getList`、`chengjiao`、`dealList` 之类的关键字; 1. 看它的 Query 参数(page、pageSize、brandId、cityId、seriesId)和 Response 结构(一个 `data.list` 数组)。 拿到真实接口后,直接请求接口比解析 DOM 稳定一个数量级。下面假设我们定位到这样一个接口(实际路径以你抓到为准): ``` 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. 最小可运行爬虫 先写一个不带代理的版本,确认接口能通: ``` 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` 追加出口地域与存活时长参数: ``` # 亿牛云动态转发代理(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 池自行调度),适用于需要精细控制并发与地域分布的场景;动态转发模式运维成本更低,更契合本文的中等规模采集。 ## 5. 数据清洗与落库 抓回来的原始 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(用唯一键 `车型+城市+上牌时间`)即可建立历史价格表。 ## 6. 拿到数据能做什么:价格—里程相关性分析 数据落库后,最有代表性的一个分析就是看「里程越高、价格越低」这件事到底有多强。用 `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) 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. 反爬与工程化注意事项 1. **限速优先**:`time.sleep` 别省,配合亿牛云轮换 IP,单机也能稳稳爬几千条; 1. **Header 完整**:`Referer`、`User-Agent` 必须带,`Accept` 标明 json,否则接口可能返回 403; 1. **UA 轮换**:准备一个 UA 池随机挑,避免单个 UA 特征被识别; 1. **签名/Token**:部分接口会校验 `sign` 或 `token`,需要逆向 JS 还原算法(这是进阶关卡,本文先不展开); 1. **字体反爬**:极少数页面用自定义字体混淆数字,需要下载字体文件做映射还原; 1. **异常兜底**:网络超时、JSON 解析失败都要 `try/except` 包住,单条失败不影响整体; 1. **合规边界**:仅抓取公开数据用于个人分析,控制频率,遵守 `robots.txt`,不碰任何隐私字段。 额外提醒一个工程化细节:**断点续爬**。批量采集经常因为网络抖动中断,建议在落盘时按 `city_id + page` 记录进度,重启后跳过已抓的页,而不是从头再来。可以用一个本地 `set` 或 SQLite 小表存「已完成的 page 键」,每次抓取前先查一下。 还有一个容易被忽略的数据质量问题:成交记录里偶尔会出现「价格 0」「里程 0」或明显离谱的异常值(比如标价误填成成交价)。清洗阶段一定要加一道规则过滤——比如 `deal_price` 低于 0.5 万或高于 500 万的直接丢弃,`mileage` 超过 50 万公里的视为脏数据。宁可少几条,也别让一个离群点把整条趋势线带歪。 ## 9. 小结 这条链路的核心就三步:**抓接口不抓页面 → 用亿牛云代理绕开限流 → Pandas 清洗落库**。接口逆向让爬虫更稳,动态代理让爬虫更久,清洗落库让数据真正可用。把成交价和里程抓全之后,你就能做任何想做的分析了——比如算某款车的「每万公里贬值曲线」,或者对比一线城市和二线城市的同车价差。