前阵子 A 同学找到我,说他们公司要给二手估价系统补全车型参数,按表单手工复制效率太低,想让我帮忙搞定一个爬虫,目标很明确:把某汽车垂直平台上的车型参数对比表完整抓下来。这个项目听起来简单,但真正动手之后才发现,难点根本不在“爬”这个动作上,而在于怎么把页面上一张动态拼接的对比表,还原成干净、规范、可入库的结构化数据。这篇文章就把完整思路、代码实现和踩坑过程记下来,给同样在做车型数据采集的朋友做个参考。无论你是要攒一份车型数据库,还是想做一个选车筛选工具,这套方案都能直接复用。
1. 需求拆解:先把参数表拆成可入库的字段结构
1.1 不要一上来就抓“对比页”的HTML
先说最容易踩的坑:直接把目标定在网页上那张对比表。用户在前端勾选几款车型,页面会生成一张包含几十甚至上百项参数的横向对比表。表面上看,这张表就是最终数据,但它背后有两层麻烦:
第一,这类对比表大多是前端 JavaScript 根据异步接口返回的数据动态渲染的。直接请求 HTML 拿到的只是一个空壳,表格内容散落在各种初始化脚本里,解析成本极高。第二,对比页的请求参数通常非常复杂,里面包含了页面标识、车型 ID 组合、排序标记、甚至防篡改字段,少传一个参数就返回空数据,排错排到你怀疑人生。
所以我的做法是反过来的:不去抢着复刻对比页,而是先把数据还原成“一条车型一条记录”的结构化数据,最后再按业务需求重组出对比表。说白了,这个项目本质上是一个“先平铺、再透视”的数据采集任务。你先把每辆车的全部参数抓下来,存进表里,后面要生成什么对比表都只是 pandas 一个 pivot 的事。
1.2 字段清单设计
动手写代码之前,第一件事不是装库,而是先想清楚:最终要拿到哪些字段?我把常用字段分成几个大类,你可以直接抄:
| 分类 | 典型字段 |
|---|---|
| 基本信息 | 车型名称、年款、官方指导价、经销商报价、上市时间、级别、能源类型 |
| 车身尺寸 | 长度、宽度、高度、轴距、前轮距、后轮距、整备质量、油箱容积 |
| 动力系统 | 排量、进气形式、最大马力、最大功率、最大扭矩、气缸数、环保标准 |
| 变速箱 | 挡位个数、变速箱类型 |
| 底盘转向 | 驱动方式、前悬挂类型、后悬挂类型、助力类型 |
| 主被动安全 | 主/副驾驶气囊、前后排侧气囊、胎压监测、车身稳定控制 |
| 辅助驾驶 | 巡航系统、驻车雷达、倒车影像、驾驶模式切换 |
| 舒适与科技 | 天窗类型、座椅材质、中控屏尺寸、车联网、大灯类型 |
字段设计要围绕业务用途。A 同学那边是做二手车估价的,所以我会额外关注“保值因子”相关的字段,比如指导价、排量、变速箱、能源类型、驱动方式;如果你要做的是选车工具,那就要重点抓天窗、辅助驾驶、座椅加热这些体验类配置。字段清单没想清楚,后面返工的代价非常高,因为不同页面的字段名可能不一样,清洗逻辑要重写。
1.3 技术方案选型:接口优先,HTML兜底
爬取这类数据,有三条路可以走:
方案 A:直接用 requests 请求页面,解析 HTML 里内置的 JSON 数据。优点是实现快,不需要额外抓包;缺点是很多页面靠异步接口渲染,HTML 里根本没有完整数据。
方案 B:打开开发者工具,找到页面触发数据加载时调用的 XHR 接口,直接请求这个接口拿 JSON。这是我最推荐的方式,稳定、高效、不受前端渲染影响,代码写起来也最舒服。
方案 C:用一键安装的浏览器自动化工具渲染整张页面,再提取表格。优点是无脑,能解决 90% 的动态渲染问题;缺点是内存开销大、运行速度慢、对目标页面结构变化极其敏感。
最后选型很明确:优先方案 B,找不到接口或接口有加密再看方案 C。你不是为了炫技,是为了在几千个车型面前稳定跑完,接口方案是唯一靠谱的。
2. 接口定位:5分钟找到比HTML更稳的数据源
2.1 用开发者工具抓XHR的实际操作
很多人说“找不到接口”,其实只是没掌握方法。我用的套路非常简单,来一个步一个步骤讲:
第一步,用无痕窗口打开目标站点,按 F12 打开开发者工具,切到“网络”面板,把筛选条件设为 XHR。第二步,在页面里搜索某个车型,点进车型详情页,再切到“参数配置”或“对比”标签。第三步,观察网络面板里新增了哪些请求,一个个点开看“响应”内容。第四步,在响应内容里按 Ctrl+F,搜索“最大功率”或者“长宽高”这类参数名,谁返回了这些字段,谁就是核心数据接口。
这个操作里有个小技巧:不要盯着列表里几十个请求发呆,只关注返回内容是 JSON、且包含参数名或车型名的请求。通常核心接口就那么一两个,找到之后把完整 URL、请求方法、请求头和请求参数都记录下来。很多网站的接口路径会带版本号或签名参数,第一眼看到可能会慌,但大多数情况下,签名参数就是时间戳加一个固定密钥,后面在代码里可以直接带常量复现。
2.2 接口的三层结构:车系、车型、参数
绝大多数汽车平台的参数数据接口,都是三层结构:
- 车系列表接口:按品牌返回车系 ID、车系名称、级别、在售状态。
- 车型列表接口:按车系返回具体车型 ID、车型名称、年款、指导价。
- 车型参数接口:按车型 ID 返回该车型完整参数表。
这里最关键的是把车型 ID(通常叫 model_id)拿到手,它后面所有参数请求的钥匙。你直接根据“车型名称”去搜索接口是不太现实的,因为名称可能变,但 ID 一般不会变。有了 model_id,再请求参数详情,就能拿到这个车型的所有字段键值对。
顺带提醒一句:接口里的字段名往往不是中文,而是拼音或英文缩写,比如“body_length”“displacement”“max_power”。第一次看到不要慌,建立一张“原始字段名 → 标准字段名”的映射表,后面清洗的时候会非常省事。
2.3 为什么不用浏览器自动化打头阵
开发阶段用 Playwright 或 Selenium 打开页面调试数据长什么样,完全没有问题。但作为生产环境的主力爬虫,非常不推荐。
原因有三个:
第一,资源占用太大。一个浏览器实例动辄 500M 内存,开五六个并发页面,普通服务器直接就扛不住了。第二,速度慢。接口方案一秒钟能拿到好几个车型的参数,浏览器自动化要等页面完全渲染,往往要十几秒一轮。第三,稳定性差。前端工程师随手改个元素 class 名,你的解析逻辑就崩了;但如果走接口,页面展示变化根本不影响数据获取。
即使你确实需要用浏览器自动化去绕开某些限制,也不要直接解析渲染后的页面文本。正确用法是:用浏览器自动化的监听网络事件,拿到数据接口的响应,保存成 JSON,再用 requests 去重放这个请求。这样等于用自动化做了一次“接口发现”,后面正式抓取还是走轻量的接口方案,既稳又快。
3. 代码实现:从请求到对比表全流程
3.1 请求封装与会话保持
选定了接口方案,就可以写代码了。我的基础库是 requests + pandas,解析用 json 内置模块。先封装一个统一的请求入口:
import requests import time import random import json session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "http://目标站点/", }) def get_json(url, params=None, retries=3): for i in range(retries): try: resp = session.get(url, params=params, timeout=10) if resp.status_code == 200 and "json" in resp.headers.get("Content-Type", ""): return resp.json() except requests.RequestException as e: print(f"请求失败: {e}, 重试 {i + 1}/{retries}") time.sleep(random.uniform(1, 2)) return None这里有个容易被忽略的点:接口返回的 Content-Type 一定要是 application/json。有时候服务器会把 JSON 包在 HTML 标签里返回,或者返回一段 JavaScript 变量,这种情况就不能直接使用 resp.json(),需要先正则提取。用 session 而不是裸 requests,是为了让 Cookie、Headers 在一次会话内保持统一,避免重复登录状态。
3.2 拉取车系与车型ID
拿到接口之后,第一步先把“车系 → 车型”的层级关系拉下来:
def get_model_list(series_id): url = "http://目标站点/api/series/detail" params = {"series_id": series_id} data = get_json(url, params) if not data: return [] models = [] for item in data["result"]["model_list"]: models.append({ "model_id": item["model_id"], "model_name": item["model_name"], "guide_price": item.get("guide_price"), }) return models这里不要一次性把所有车系全跑完,先拿三个品牌做小样本验证。我曾经图省事直接全量跑,跑到一半发现某个品牌的参数接口字段名跟其他品牌不一样,导致一整批数据都解析错了。小样本验证可以帮你尽早发现问题,等确认字段映射没问题之后,再放开跑全量。
另外,分页是必须考虑的。如果车系列表接口是分页的,那就循环请求每一页,直到返回空列表为止;如果支持每页条数设置,直接调大可以减少请求次数。但凡事有度,一次请求拉 1000 条和拉 10 条对服务器的压力完全不同,别为了减少请求次数把单次条数拉得过大,容易被限流。
3.3 拉取单车型参数并解析
拿到 model_id 列表后,逐个请求参数详情接口。写一个解析函数,把原始字段映射成标准字段:
def get_model_spec(model_id): url = "http://目标站点/api/model/spec" params = {"model_id": model_id} data = get_json(url, params) if not data: return None result = data["result"] spec = { "model_id": model_id, "model_name": result.get("model_name"), "guide_price": result.get("guide_price"), "vehicle_level": result.get("level_name"), "body_length": result.get("body_length"), "body_width": result.get("body_width"), "body_height": result.get("body_height"), "wheelbase": result.get("wheelbase"), "displacement": result.get("displacement"), "max_power": result.get("max_power"), "max_torque": result.get("max_torque"), "gearbox": result.get("gearbox"), "drive_mode": result.get("drive_mode"), } return spec这段代码看起来简单,但真实情况比这复杂得多。第一,接口字段不一定叫 body_length,可能是 bodyLength、body_len、LWH 之类的缩写,需要你看一遍响应后自己建映射。第二,有些字段的值是对象而不是字符串,比如 max_power 可能返回 {"value": 150, "unit": "kW"},解析时要做判断,避免拿到 dict 直接入库。第三,有些字段的值包含单位,比如“150kW”,存储时建议把数值和单位拆开,否则后面做数据分析时还要再清洗一次。
这一步我建议顺手做一件事:把每个 model_id 的原始响应保存一份 log。千万别觉得多此一举,等你要排查某个车的参数为什么是空的时候,这份 log 能救你的命。存 JSON 文件或者丢进 MongoDB 都可以。
3.4 对比表生成与导出
全部车型参数拉完之后,用 pandas 把它们拼成一张对比表。按“行=参数项、列=车型”的方式组织最直观:
import pandas as pd all_specs = [] for model_id in model_id_list: spec = get_model_spec(model_id) if spec: all_specs.append(spec) time.sleep(random.uniform(0.5, 1.5)) df = pd.DataFrame(all_specs) df.set_index("model_id", inplace=True) # 转置成对比表:行是参数项,列是车型名称 compare_df = df.T compare_df.columns = df["model_name"].tolist() # 注意:这里需要先把model_name存出来 compare_df.to_csv("车型参数对比表.csv", encoding="utf-8-sig")导出 CSV 记得用utf-8-sig,不然用 Excel 打开中文会乱码。如果你想要一个好看的对比表,可以把索引列改成两级:第一级是参数分类(基本信息、车身尺寸、动力系统),第二级是具体参数名,这样输出后层次更清楚,别人一看就懂。Excel 导出用 openpyxl 引擎:
with pd.ExcelWriter("车型参数对比表.xlsx", engine="openpyxl") as writer: compare_df.to_excel(writer, sheet_name="对比表")跑完之后记得抽查几个车型,对比一下源站页面上的数字和表格里的数字是否一致。不要以为程序不报错就代表数据是对的,经常会出现字段错位、单位不一致这类问题,抽查是成本最低的质检方式。
4. 数据清洗与去重建模
4.1 字段归一化与单位统一
接口里秒回的数据往往没你想的那么干净。同一个参数,不同车系可能给你不同的写法。举个例子:
- 功率字段:有的返回“150kW”,有的返回“204马力”,还有的返回“150kW(204Ps)”。
- 排量字段:有的返回“1.5T”,有的返回“1498mL”,有的返回“1.498L”。
不归一化,这些数据根本没法对比。我的处理思路是:先把值从字符串里抽出来,数值和单位分开存,然后把单位统一换算成标准单位。比如功率统一转成 kW,如果原值包含“马力”,公制马力换算关系就是 1 马力 = 0.735 千瓦,直接用浮点运算转换即可。
写一个简单的清洗函数:
import re def normalize_power(raw_value): """把 150kW / 204马力 / 150kW(204Ps) 统一成 kW 数值""" text = str(raw_value).lower().replace(" ", "") if not text or text in ["暂无", "-"]: return None match = re.search(r"(\d+(\.\d+)?)\s*(kw|马力|ps)", text) if not match: return None value = float(match.group(1)) unit = match.group(3) if unit == "马力" or unit == "ps": value = round(value * 0.735, 1) return value这种函数看着简单,但它是整个数据质量的保底防线。清洗逻辑写成函数还能方便单元测试,后面拿到新数据直接复用,不用每次手动改。
4.2 缺失值的业务含义
参数表里最常见的缺失值是“暂无”“-”“null”这类占位符。清洗时统一转成 None 或者空字符串,不要留着“暂无”两个字往表里写。
但有一点要特别注意:有些字段的缺失是正常业务逻辑,不是数据有问题。比如新能源车没有“油箱容积”,手动挡车型可能没有“挡位个数”数据,部分低配车没有“倒车影像”。如果这时候你直接把这些字段删了,或者填 0,就会造成极大的误导。正确做法是保留字段,缺失值用None表示,并额外加一个数据来源版本字段,方便以后追溯这批数据是从哪个接口、哪天抓下来的。
4.3 唯一键设计与增量更新
车型参数数据天然有一个稳定主键:model_id。不管你重跑多少遍,只要 model_id 不变,就是同一辆车。所以不要用“车型名称”作为去重键,因为同名改款车太多了,比如“某车型 2023 款”和“某车型 2024 款”,名称非常相似但完全是两台车。
我的增量更新策略很简单:
- 先把上次抓到的 model_id 集合加载到内存。
- 新抓到的 model_id 如果已经存在,跳过;不存在,则抓取。
- 每次抓取后把 model_id + 更新时间写入一个历史表。
- 定期全量重抓一次,用于处理参数修改的场景。
如果你要监控的价格波动,那还要设计一张价格历史表,每次抓到的指导价都追加一行,加时间戳,这样才能画出价格变化曲线。直接覆盖式存储是做行情监控最大的忌讳。
5. 高频问题排查与反爬韧性设计
5.1 请求频率限制与随机延迟
几乎所有带数据接口的网站都会有频率限制。表现就是:前几百个请求还正常,突然某一次开始返回空白、验证页面或者非 JSON 内容。解决方案特别朴素:限速。
不要一下把并发开满。我用的是最简单的策略:单线程循环,每次请求之间随机sleep 0.5~1.5秒。1000 个车型跑下来大约需要 20~40 分钟,这个时间完全可接受。如果你非要提速,可以用多线程,但建议限制在线程数 3~5 个,同时把随机延迟拉大到 1~3 秒。
这里要加一个通用兜底逻辑:
import time import random def request_with_backoff(url, params=None, max_retries=5): base_delay = 2 for i in range(max_retries): resp = make_request(url, params) if isinstance(resp, dict) and "error" not in resp: return resp delay = base_delay * (2 ** i) + random.uniform(0, 1) time.sleep(delay) return None指数退避重试,前几次很小,后面越拉越长,给服务器喘息时间,也给自己留一条活路。
5.2 Cookie 与登录态失效
很多参数接口不需要登录,但有一些“经销商报价”“车主成交价”这类数据是登录后才可见的。这种场景下,我的做法是:
- 先在浏览器里手动登录一次目标站点。
- 从开发者工具里复制当前 Cookie,保存到一个文件。
- 脚本启动时读取 Cookie,赋值给 session。
需要注意,Cookie 会过期,尤其是登录态一般维持不了多久。更稳妥的做法是程序里做两层判断:第一,每个请求返回之后检查是否被重定向到登录页;第二,一旦发现返回的数据结构异常,立刻停止任务并输出“Cookie 过期”日志,等重新登录之后再继续跑。
5.3 接口签名与加密参数
有些平台为了防爬,会在接口上加签名参数,常见的是sign或token。拿到这种参数不要慌,先分析它的生成规则:
- 一种常见做法是时间戳 + 固定密钥做 MD5,这种你可以自己在代码里复现。
- 另一种是把请求参数按字典序拼接后做哈希,也可以复现。
- 还有一种是登录用户特有的 token,这时候就得走登录流程获取。
如果实在解不开,就用浏览器自动化或者是偶尔手动复制一次完整的 URL,把带签名的地址缓存复用。这个方案虽然不太优雅,但作为兜底完全够用。
5.4 字段消失与页面改版
做爬虫最怕的不是被反爬封禁,而是目标站点改版。前端页面可以随便改,接口字段也可以随便改,改完之后你的解析逻辑就全废了。
所以我在设计清洗层时,尽量用适配器模式:每个字段一个小函数,抓不到就返回 None 并打印警告。这样即使某个字段失效了,其他字段仍然正常入库,不会导致全盘崩溃。日志里会看到什么东西缺失了,再针对性地去研究是不是改版了。
还有一个很实用的小技巧:在每个车型的原始响应里,把接口返回的字段名集合都记录下来。连续跑几天之后,你就有了一份“字段名出现频率”图谱,一旦某一天某个字段名不再出现,第一时间就能发现变更,不用等业务方来反馈“数据少了”。
5.5 一个关于抓取频率的忠告
不管你的技术多牛、目标站点多难搞,爬虫的前提一定是合理克制地使用公开数据。我在每个项目里都会限制最高并发、加随机延时、设置每日抓取量上限。不是胆量问题,而是长期主义的考虑——目标站点挂了,你的数据管道也会挂;你把它压垮了,大家都没得用。克制一点,对双方都安全。
6. 应用扩展:参数表数据还能怎么用
6.1 做车型筛选工具
参数表一旦落地,最简单的应用就是做“条件筛选”。A 同学拿到数据后,第一件事就是做了一个小 Demo:输入预算区间、能源类型、变速箱类型,跑出来符合条件的车。
用 pandas 做筛选其实就是几行代码的事:
filtered = df[ (df["guide_price"].between(100000, 150000)) & (df["energy_type"] == "汽油") & (df["gearbox"].str.contains("自动")) & (df["body_structural_stability"] == "有") ]对二手车估价团队来说,这个筛选逻辑可以反向输出——比如先确定估价模型需要哪几类特征,再从参数表里把对应车型列表摘出来,效率比从业务库里逐个车型翻高得多。
6.2 定时任务与行情监控
参数表里的价格、配置都不是一成不变的。新车上市、改款、官降、经销商促销,都会让参数和价格发生变化。利用 APScheduler 或者系统定时任务,每天固定抓取一次增量数据,写进数据库,就能形成一条完整的价格走势时间线。
这个扩展方向价值很大:参数采集一次是数据,持续采集就变成资产。二手车估价里非常需要“当年新车价格波动情况”,有了时间线,模型训练样本就更多了一个维度。
from apscheduler.schedulers.blocking import BlockingScheduler scheduler = BlockingScheduler() scheduler.add_job(main_crawl, "cron", hour=2, minute=30) scheduler.add_job(daily_price_check, "cron", hour=6, minute=0) if __name__ == "__main__": scheduler.start()注意定时任务的抓取间隔不要短于几小时一次,而且每次都要走增量,不要全量重刷同一批数据,给自己服务器和对端站点都留足空间。
6.3 可视化分析与报表
把参数表里的数据聚合一下,能做出很多有意思的结论。比如:近三年的新车型平均功率是不是一直在涨?紧凑型轿车的轴距是不是越拉越长?这些统计对做行业分析或内容选题都非常有用。
用 pandas 聚合加 matplotlib 或 pyecharts 就能画图。例如按能源类型分组统计平均功率:
df.groupby("energy_type")["max_power_kw"].mean().plot(kind="bar")做这种分析时要注意口径统一:过滤掉无效记录、统一单位、明确时间范围,不然结论会严重失真。
6.4 模型特征工程
回到 A 同学的真实场景:二手车估价。参数表对估价模型来说就是一份特征表。哪些字段可以作为特征?我的经验是:
- 指导价肯定是最重要的基础特征。
- 动力参数(功率、扭矩、排量)影响驾驶体验,和保值率有一定相关性。
- 变速箱类型对二手车价格影响很大,自动挡和手动挡的残值差距明显。
- 能源类型更是决定性因素。
- 车身稳定控制、胎压监测这些安全配置对价格影响相对弱,但在某些细分市场很关键。
做好特征字段的清晰映射和缺失值处理,等于节省了后面整个建模阶段 80% 的清洗工作量。这也是为什么我在前期字段设计上花的时间最多。
这个项目全部跑通的之后,我的感受是:爬取本身没有太多高深的东西,真正有价值的在于“数据建模 + 清洗 + 长期维护”这套工程思路。只要你把目标站点的数据结构吃透,把字段映射表建好,把增量更新和异常日志跑通,无论目标站点怎么改版,这套框架都能很快适应。对正在做车型数据采集或者版本参数对比需求的朋友,希望这篇文章能帮你少走几个弯路。