Python 爬虫实战:抓取汽车之家二手车成交价格与里程数据

  1. 为什么抓二手车成交数据

二手车是典型的非标品:同一款车,因为上牌年份、行驶里程、所在城市、车况等级不同,价格能差出好几万。汽车之家二手车(che168.com)聚合了全国大量真实成交记录,是做残值率分析、定价模型、区域行情监控最省钱的公开数据源。

我们自己要做定价参考时,最关心的两个字段就是:

  • 成交价格(dealPrice):车主实际卖出的价格,比挂牌价更有参考价值;
  • 行驶里程(mileage):衡量车辆损耗的核心指标,通常和价格呈强负相关。

把这两个字段加上车型、城市、上牌时间一起抓下来,就能画出「某车型 / 某市 / 某年款」的价格---里程曲线。举个实际场景:你想买一辆三年的丰田凯美瑞,挂牌价从 11 万到 14 万都有,到底哪个算捡漏?把本地近半年的真实成交记录拉出来,按里程分组算中位数,再对照目标车的里程,一眼就知道报价合不合理。这种「用真实成交价给挂牌价做基准」的用法,比任何估值工具都更贴地气。

更重要的是,成交数据是时间序列的------同一款车这个月成交价 12.3 万,下个月 11.8 万,背后可能是新车降价、排放标准切换或季节因素影响。持续抓取、积累历史,你手里就有了一张动态行情表,而不是某个时间点的快照。这正是爬虫相比「手动查一次」最大的价值:它把一次性动作变成了可复用的数据流。

2. 接口分析:别爬 HTML,抓 JSON 接口

直接用 requests 把成交列表页拉下来再解析 HTML 是最笨的做法------页面结构一改就全废,而且接口字段往往藏得深。正确姿势是监听接口

  1. 打开 che168.com 二手车成交页,按 F12 进 DevTools;
  2. 切到 Network 面板,勾选 XHR / Fetch
  3. 刷新页面,找返回 application/json 的请求,通常带 getListchengjiaodealList 之类的关键字;
  4. 看它的 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 里真的有 dealPricemileage 这些字段,以及分页是否靠 page 还是 pageIndex 控制;
  • Cookie 可能不是必须的 :很多列表接口是公开匿名接口,但带上一两个关键 Cookie(如 fvlidsessionid)会更像真人,降低被拦概率;
  • 参数别多带:只保留翻页和筛选相关的参数,多余的参数可能是签名校验来源,少带反而更干净;
  • 注意 gziprequests 默认会自动解压,但如果自己用 httpxaiohttp,记得开启 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)}")

字段映射时留意:成交价通常在 dealPriceprice 里(单位元),里程在 mileage(单位万公里)或 licheng,上牌时间在 registerDate / cardDate以你实际抓到的 JSON 为准,不要照抄字段名。

4. 接入云代理 IP

单机直连可用于小规模采集,但批量抓取全国数据时,目标站点会基于出口 IP 做限流或封禁,引入代理 IP 是必要手段。

本文采用亿牛云(16yun.cn)动态转发代理 :所有请求统一经其转发网关出口,由后台按请求轮换出口 IP,调用方无需自建与维护 IP 池。接入方式即在 requestsproxies 中配置转发网关,认证凭据为订单号(用户名)与订单密码,用户名可按 -地区-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. 反爬与工程化注意事项

  1. 限速优先time.sleep 别省,配合亿牛云轮换 IP,单机也能稳稳爬几千条;
  2. Header 完整RefererUser-Agent 必须带,Accept 标明 json,否则接口可能返回 403;
  3. UA 轮换:准备一个 UA 池随机挑,避免单个 UA 特征被识别;
  4. 签名/Token :部分接口会校验 signtoken,需要逆向 JS 还原算法(这是进阶关卡,本文先不展开);
  5. 字体反爬:极少数页面用自定义字体混淆数字,需要下载字体文件做映射还原;
  6. 异常兜底 :网络超时、JSON 解析失败都要 try/except 包住,单条失败不影响整体;
  7. 合规边界 :仅抓取公开数据用于个人分析,控制频率,遵守 robots.txt,不碰任何隐私字段。

额外提醒一个工程化细节:断点续爬 。批量采集经常因为网络抖动中断,建议在落盘时按 city_id + page 记录进度,重启后跳过已抓的页,而不是从头再来。可以用一个本地 set 或 SQLite 小表存「已完成的 page 键」,每次抓取前先查一下。

还有一个容易被忽略的数据质量问题:成交记录里偶尔会出现「价格 0」「里程 0」或明显离谱的异常值(比如标价误填成成交价)。清洗阶段一定要加一道规则过滤------比如 deal_price 低于 0.5 万或高于 500 万的直接丢弃,mileage 超过 50 万公里的视为脏数据。宁可少几条,也别让一个离群点把整条趋势线带歪。

9. 小结

这条链路的核心就三步:抓接口不抓页面 → 用亿牛云代理绕开限流 → Pandas 清洗落库。接口逆向让爬虫更稳,动态代理让爬虫更久,清洗落库让数据真正可用。把成交价和里程抓全之后,你就能做任何想做的分析了------比如算某款车的「每万公里贬值曲线」,或者对比一线城市和二线城市的同车价差。

相关推荐
深盾科技_Virbox1 小时前
软件加密工具怎么选:试用验证、采购成本与授权范围
开发语言·安全·软件需求
A_cainiao_A1 小时前
C++20:std::stop_source让线程说停就听
开发语言·c++20
Jazz_z1 小时前
如何用Python将彩色PDF一键转为黑白(灰度)
java·python·pdf
SEO_juper2 小时前
2026年Schema自动注入实战:用Python批量给1000个页面加上JSON-LD,AI引用率实测提升38%
人工智能·python·json·seo·独立站
ws2019072 小时前
从实验室到路测场:2026广州测试测量展见证汽车品质新高度
科技·汽车
提升汽车音响好物2 小时前
2026广州新能源汽车蔚来ES8音响升级施工记录:多单元系统如何重新安排车内声场
汽车·音频
fīɡЙtīиɡ ℡2 小时前
深入学习JAVA并发编程(上)
java·开发语言·学习
zzzll11112 小时前
HashMap、HashTable、ConcurrentHashMap 详细区别与深度解析
开发语言·python
程序员AI工坊2 小时前
Agent 开发:ReAct 循环与工具调用实战——从单次调用到自主 Agent
人工智能·后端·python·langchain·agent·react