
做爬虫这么多年我一直觉得汽车之家是个特别适合拿来练手的站点。它数据量够大、层级清晰、结构规整而且涵盖了列表页、详情页、分页、筛选条件、价格区间这些爬虫新手几乎都会遇到的经典场景。这篇文章记录的就是一次完整的实战用最基础的 Python 爬虫 技术栈从零开始抓取汽车之家的车型报价信息最终生成一份可用的结构化数据集。整套代码我都会拆开讲清楚适合刚学完 Python 基础、想动手做第一个正经爬虫项目的同学也适合做车型比价、价格波动分析、二手车参考这类小工具的开发者。整个项目没有用 Scrapy 这类重型框架requests XPath 足够完成。你不需要掌握复杂原理跟着步骤走完就能掌握一套可以平移到绝大多数垂直站点的爬虫套路。1. 项目定位与合规准备1.1 为什么选汽车之家作为爬虫练手目标我见过很多人一上来就问“用什么框架爬”结果框架学了半天连页面里想拿什么数据都没想清楚。其实选目标站点比选工具重要得多。汽车之家这个站在爬虫学习里属于“教科书级”的结构品牌、车系、车型三层层级列表页负责概括详情页负责细节数据字段基本都规整地躺在固定的 DOM 节点里不需要处理大量动态渲染的 Canvas 或者加密接口。换句话说你在这上面练会一套流程换到别的垂直行业站点也能快速迁移。更关键的是车型报价这个数据本身有真实的应用价值。你可以拿它做价格趋势分析、同级别车型横向对比、指导价与经销商报价的差异统计甚至喂给图表库画出价格分布。这些应用场景不需要登录、不需要用户隐私数据全部是公开页面能看到的非敏感信息作为个人学习和技术实践完全合适。这个项目的定位就是“公开数据的结构化采集”不在灰色地带里打转。1.2 合规边界先看协议再谈技术说实话现在不少新手一看到“反爬”两个字就兴奋满脑子是想怎么绕过对方的防护。我的建议恰恰相反作为一个练习项目你的第一优先是搞清楚哪些内容能碰、以什么频率碰。写代码之前建议先打开目标站点的 robots 协议看一眼。汽车之家这类大型垂直站点对爬虫的约束分为几种情况有的路径明确允许普通访问有的路径标注了不允许抓取还有一部分需要登录态的页面本来就不该爬。作为学习项目只访问公开的列表页和详情页就够了涉及账户体系、个人中心的内容一律绕开。频率控制更是基本功。我见过新手写的爬虫一旦跑起来就不停歇连续几个小时高频请求结果无非两种IP 被临时限制或者给目标服务器造成不必要的压力。这既不是技术能力也谈不上效率。合理做法是单线程或低并发每次请求之间留出 1 到 3 秒的随机间隔。这个节奏对个人学习项目来说完全够用也能让双方都舒服。你要真遇到需要大规模采集的场景正确路线是和站点建立正规合作而不是硬扛反爬。提示爬虫最忌讳的就是一上来就全速抓取。你用单线程、间隔两秒的节奏去抓绝大多数站点根本不会注意到你。别一上来就开几十个并发除非你是对方明确授权的数据合作方。1.3 项目功能边界与产出物这个实战项目的目标非常具体抓取汽车的品牌、车系、官方指导价、经销商参考价以及车型年份、排量、变速箱等基础参数。不采集用户评论、不采集车主个人信息、不做价格历史追溯。最终产出一份 CSV 文件每行一辆车字段清晰方便直接用 Excel 或 pandas 打开做分析。我之所以强调“边界”是因为爬虫项目的失败往往不是因为抓不到数据而是因为字段越加越多、需求越扩越大最后代码变成一团乱麻。新手最容易掉进这个坑。你先把“车系名称 价格区间”跑通再逐步加字段比一开始就追求全量字段要稳妥得多。这个项目的核心价值不在于“爬得多”而在于“流程完整、代码可复用”。2. 技术选型与整体架构2.1 requests XPath为什么不用重型框架技术选型上我最后确定的是 requests 负责网络请求lxml 配合 XPath 做页面解析pandas 或 csv 模块做数据落盘。没有用 Scrapy也没有用 Selenium原因很直接页面结构足够规整动态渲染的部分对这个项目影响很小用同步请求加静态解析就能搞定引入重型框架反而会增加学习成本。很多初学者分不清什么时候用 XPath、什么时候用 BeautifulSoup。我这里给你一个简单的判断标准如果页面的数据点固定、结构有规律XPath 更直接高效如果页面结构杂乱、需要各种容错处理BeautifulSoup 的 find 系列方法更容易上手。对于汽车之家这种层级分明的列表页XPath 的“从节点往下找指定路径”能力非常贴合需求。而且 lxml 的解析速度明显快于纯 Python 实现的解析器数据量大了以后差距很直观。还有一点我要泼盆冷水Selenium 是最后的兜底方案不是首选方案。它启动浏览器、加载页面、等待渲染整个过程又慢又吃内存。只有当目标页面依赖大量 JavaScript 动态加载、接口加密无法绕过时才值得考虑。你先分析页面源码能拿到数据的绝不启动浏览器。2.2 请求头伪装与基础反爬应对任何一个正经爬虫项目请求头都是第一道必须做好的门面。默认的 Python-requests 请求头特征非常明显直接暴露“我是脚本”的身份。正确做法是模拟一个常见浏览器的 UA再补上 Accept-Language、Accept、Connection 等常见字段。这里的逻辑不是“破解反爬”而是把一个自动脚本伪装成正常访客的样子降低被误判的概率。我见过很多新手只设一个 User-Agent 就觉得完事了其实还不够。有一些站点会检查请求的完整性比如 Accept 头是否包含正常的 MIME 类型、Accept-Language 是否符合地域特征。你可以自己写一个浏览器抓包看一下正常的请求头长什么样然后把常用的几个字段设置进去。你也可以在代码里维护两三个常用 UA 字符串每次请求随机取一个。这个做法规避了单一 UA 过于固定导致的识别问题成本极低。不要做的事情我也说明白不搞验证码识别、不搞代理池对抗、不绕过登录态。这些手段既可能触碰法律红线也会让你的项目偏离学习初衷。普通个人项目把 UA 伪装、请求间隔、重试机制做好就足够应对绝大多数正常访问场景了。2.3 整体流程与代码结构规划动手写代码之前我把整个流程在脑子里过了一遍最后拆成四个模块请求模块、解析模块、存储模块、控制模块。请求模块负责发请求、处理超时和重试解析模块负责从 HTML 里提取结构化数据存储模块负责把数据写入 CSV控制模块负责调度前三个模块并处理分页和限速。这种分模块的写法看起来比一个几十行的单文件“脚本”要麻烦但实际工程收益非常大。你换一个目标站点时只需要重写解析模块换一种存储格式时只改存储模块请求频率调整时只改控制模块。我自己的经验是把一个爬虫项目当成小工程来组织后续维护和复用的成本能下降一大半。下面我会逐块展开讲讲核心代码。3. 核心源码逐段拆解3.1 URL规律与页面结构分析分析目标站点的第一步永远不是写代码而是打开浏览器开发者工具选中“网络”面板人工浏览几个页面观察 URL 的变化规律。汽车之家的结构典型呈现为三层品牌页展示若干车系车系页展示若干具体车型车型详情页展示完整配置和报价。这三层的 URL 之间有明显规律品牌页到车系页通过固定路径加品牌 ID 衔接车系页到车型页通过车系 ID 衔接。把这种规律记下来你的爬虫就不再是“瞎点链接”而是“按图索骥”。当然网站改版是常态我不能保证你看到的结构和我写这篇文章时完全一致。所以有一件基本功你必须养成在写解析逻辑之前先右键查看页面源代码确认目标数据的 HTML 结构。如果你要抓的数据在页面源码里找不到但浏览器里能看到说明是异步加载的那就要另想办法如果源码里就有那就用 XPath 直接提取。这个判断过程决定了后续代码怎么写。以列表页为例页面里通常包含一排排的条目节点每个条目节点内部有车系名称、价格区间、图片链接等信息。我习惯先在开发者工具的 Elements 面板里定位到一个条目右键选择复制 XPath看一下生成的路径特征再改成通用的循环路径。3.2 请求模块封装、重试与编码处理请求模块是整个爬虫的地基写好的标志是“既能容忍临时性网络异常又能明确暴露真正的问题”。我用一个简单的 fetch_page 函数封装网络请求逻辑核心包含超时设置、状态码检查、重试机制三部分。代码如下import time import random import requests HEADERS { 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: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, } def fetch_page(url, headersNone, retries3): if headers is None: headers HEADERS for attempt in range(retries): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: # 根据网页声明的编码来解码避免中文乱码 return resp.text else: print(f[WARN] 页面返回 {resp.status_code}: {url}) except requests.RequestException as e: print(f[ERROR] 请求异常: {e}, 第 {attempt 1} 次重试) if attempt retries - 1: time.sleep(random.uniform(2, 5)) return None这里有一个非常容易踩的坑编码处理。有些页面返回的响应头里带着 charset 声明有些没有。一般情况下requests 会用响应头里的编码去解码但遇到没有声明或声明不一致的页面就会出现乱码。稳妥做法是拿 resp.encoding 判读或者使用 resp.apparent_encoding 做兜底。我习惯在请求时先把resp.encoding设置为resp.apparent_encoding或者直接根据页面meta charset...手动指定。重试机制的间隔也值得单独说一下。我在代码里用random.uniform(2, 5)生成不固定的延迟时间这个做法背后有讲究固定间隔本身是一种容易被识别的机器特征而且一旦目标站点临时限流固定间隔的连续重试可能更难恢复。随机化不是玄学是让请求节奏更接近人工浏览的行为模式。3.3 解析模块XPath提取与价格清洗拿到 HTML 文本之后解析模块负责把它转成结构化字典。使用 lxml 的 fromstring 方法把字符串解析成一个可以执行 XPath 的文档树然后找到每个条目节点再从条目节点内部继续提取字段。以车系列表页为例核心逻辑是这样from lxml import html def parse_series_list(page_text): doc html.fromstring(page_text) items doc.xpath(//li[contains(class, list-item)]) result [] for item in items: name_nodes item.xpath(.//a[contains(class, title)]/text()) price_nodes item.xpath(.//span[contains(class, price)]/text()) if not name_nodes: continue name name_nodes[0].strip().replace(\n, ) # price 文本类似 12.98万 或 9.89-13.99万 price_raw price_nodes[0].strip() if price_nodes else result.append({ 车系名称: name, 价格文本: price_raw, }) return result这样解析看起来简单但实际运行中会遇到三个经典问题。第一XPath 表达式返回的是一个列表不是单个字符串初学者经常忘了在后面加[0]或者没做空列表判断导致 IndexError。第二页面文本里可能混着换行、多个空格直接strip()不够还需要把内部的换行符替换掉。第三价格字段往往带“万”“起”“-”这类符号你不能直接当作数值用。所以需要单独写一个清洗函数import re def extract_price_numbers(text): 从 12.98万 或 9.89-13.99万 中提取数字部分返回 float 列表 nums re.findall(r\d\.?\d*, text) return [float(n) for n in nums]我个人建议把“解析原始文本”和“清洗数据”分成两个步骤不要写成一个超长表达式。拆开之后每个函数都容易测试出错了也容易定位。你可以在命令行里单独测试extract_price_numbers的输入输出不用每次跑全流程开发体验会好很多。到了车型详情页这一层解析逻辑会稍复杂一些。详情页里有车型名称、官方指导价、排量、变速箱、最大马力等字段。这些字段分布在不同的区块中但只要你定位到包含“参数配置”或“车型报价”的区块用同样的 XPath 思路逐字段提取即可。我要提醒的是同一类型的字段在不同车系页面上可能有细微差异比如有的车系没有“最大马力”项有的车系则多出“电动机总功率”。设计字典时采用“有则提取没有就填 None”的方式最省心。3.4 存储模块CSV落盘与增量保存数据提取出来以后怎么存也是个必须考虑的问题。我这里选择 CSV 格式因为它兼容性最好Excel 能打开、pandas 能读甚至文本编辑器看一眼也不乱码。写 CSV 有一个关键细节如果直接用默认编码写入中文在 Excel 里会变成乱码因为 Excel 默认按 GBK 读取。解决方法是把文件编码设为utf-8-sig。这个编码会在文件开头加上 BOM 标记Excel 识别后就能正确解码中文。代码如下import csv def save_rows_to_csv(filename, rows, fieldnames): with open(filename, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) # 文件为空时先写入表头 f.seek(0, 2) if f.tell() 0: writer.writeheader() writer.writerows(rows)我用的是a追加模式每次把新解析出来的行追加到文件末尾。这个设计和后面的“断点续爬”配合起来很方便程序中断导致某个车系没爬完下次启动时直接继续追加不会覆盖之前的成果。如果你爬的数据量很大比如几百个车系、几千条车型数据也能避免一次性把全部数据都攒在内存里导致内存膨胀。字段顺序我建议保持固定多车系运行时尤其重要。fieldnames 列表写清楚后续如果要加字段记得把旧数据的 CSV 头也手动补齐否则 pandas 读进来字段会对不上。这是很多新手会忽略的坑。4. 实操过程与细节复盘4.1 先跑通一条链路再考虑全量我做一个爬虫项目的习惯是“先纵向打通再横向扩展”。什么意思就是说第一版本代码不追求把所有车系都抓完而是先挑两条代表性路径比如一个热门品牌、一个冷门品牌从品牌页跑到车型详情页确认每条链路都能拿到数据再放手让程序全量跑。这样做的原因是垂直站点的页面结构往往不是完全统一的。热门品牌数据维护得勤快详情页字段完整冷门品牌可能存在字段缺失、页面布局差异、车系跳转异常等情况。如果你一上来就全量跑可能跑到第三个车系就报错而此时你根本分不清是网络问题、解析逻辑问题还是数据本身就缺失。我在这个项目里先手动浏览了三个品牌的页面给每个品牌的首页、车系页、详情页分别做了截图标出要提取的数据点。然后写了一个精简版的测试脚本只抓一个车系并打印结果。确认输出正确后再逐步加入重试、限速、存储等外围功能。这个流程看着慢实际是快的因为错误都被限制在小范围内。4.2 限速、日志与进度追踪全量运行时一个不能省的就是进度日志。我不建议用print一坨糊脸上而是按批次输出关键信息当前品牌、当前车系、抓取到多少条车型数据、累计耗时。这样程序跑着的时候你不用死死盯着屏幕回头翻日志就能知道整个过程的健康度。我常用的日志风格很简单每次抓完一个车系后输出一行[INFO] 2025-01-15 14:03:22 车系: 某品牌A级车 完成车型数量: 12当前累计: 45 [INFO] 2025-01-15 14:05:08 车系: 某品牌SUV 完成车型数量: 8当前累计: 53这块代码我不单独复制了因为实现难度极低关键在于你是否愿意加。有些新手觉得日志是多余的“数据能出来不就行了”。但真实项目里一次全量爬取可能持续十几分钟甚至几个小时中途如果断掉没有日志你根本不知道已经处理到哪里、断了之后该从哪里续。加上日志你的爬虫就从“一次性脚本”变成“可运维的小工具”。限速方面我在请求模块里已经用了随机延迟。控制层要做的是保证每次请求之间确实有 sleep。这里有一个细节如果你在循环里忘了加 sleep哪怕请求模块里写了重试延迟正常请求也是连发状态。正确的做法是把 sleep 放在每次抓取动作的最后或者放在循环体的固定位置。4.3 断点续爬与异常跳过设计全量爬取时最痛的问题就是“跑到一半断掉”。断网、服务器主动断开连接、程序异常崩溃都有可能。应对思路就是断点续爬每处理完一个车系就把它的状态记录下来下次启动时读取状态文件从已完成的车系之后继续。实现方式很简单维护一个集合记录“已完成的 URL 或车系 ID”每次启动时读取这个集合在循环里判断当前车系是否已存在。这个判断逻辑放在循环顶部几行代码的事却能让你免去重复劳动。存储这些状态我习惯用 JSON 文件理由是小项目里 JSON 比数据库更直观、可读性好也不容易出错import json import os STATE_FILE progress.json def load_state(): if os.path.exists(STATE_FILE): with open(STATE_FILE, r, encodingutf-8) as f: return set(json.load(f)) return set() def save_state(done_set): with open(STATE_FILE, w, encodingutf-8) as f: json.dump(list(done_set), f, ensure_asciiFalse)另一个实用技巧是“单条数据失败不阻塞整体”。比如某个车型详情页因为网络问题连续三次请求失败你应该记录一条 warn 日志然后继续下一个车型而不是让整个程序停下来。只有当日志里出现连续大量失败时才需要人为介入检查是不是请求频率太快或出了其他问题。5. 常见问题与故障排查5.1 请求被拒403、429与超时跑爬虫最常见的问题就是请求返回 403 或 429。403 表示服务器拒绝访问429 表示请求过于频繁。新手第一反应往往是“我被反爬了”但其实大部分情况只是你的请求特征太明显或者速率控制没做好。排查顺序我建议先从最简单的开始检查 headers 是否完整是不是用了默认的 Python 请求头接着看两次请求之间的间隔是否够长再确认自己的 IP 是不是已经被临时限制。如果请求间隔已经放到 5 秒以上还出现 429那就要考虑是不是某个并发太高或者目标站点正在做临时限流。这个阶段把 sleep 时间加倍、减少单批次请求数量是最有效的调节手段。不要一遇到 403 就想用代理池。个人学习项目里代理池不仅增加复杂度还可能引入新的合规风险。你要做的是用合规的方式来访问公开数据。5.2 解析结果为空或字段错位XPath 写完之后跑出来的结果经常是空列表。排查这个问题我提供一个高效套路第一步在代码里打印出拿到的 HTML 片段确认页面结构和你预期一致。第二步在开发者工具里用搜索功能验证 XPath 表达式能否匹配到节点。第三步检查是否存在隐藏节点、iframe 嵌套、图片懒加载等干扰。汽车之家这类站点的列表项有时会包含多种类型的卡片比如“热门车系”和“普通车系”卡片结构不完全一样。如果用一条 XPath 硬匹配很容易漏掉一部分。解决办法是用一条更宽泛的路径匹配所有候选节点然后在循环里用二次 XPath 判断节点类型分情况提取字段。我遇到过一次很隐蔽的问题某个字段在页面上有很多相似节点但真正的目标节点被包在了一个隐藏的div里导致提取到的数据全部是模板占位值。这种问题只能靠打印少量数据人工比对来发现。所以我在解析模块里通常会带一个可开关的调试模式打印前三条解析结果跑通之后再关掉调试输出。5.3 中文乱码与编码问题页面出现中文乱码十有八九是解码环节出了问题。前面提过requests 默认会参考响应头里的编码但有些服务器返回的响应头不标准这时候乱码就出现了。排查方法是先打印resp.encoding看看 requests 认为页面是什么编码再看页面meta标签里实际声明的编码。两者不一致时以页面声明为准手动设置。还有一种情况是页面用 gzip 压缩且压缩方式不标准requests 解压后文本变成了乱码。这种情况比较少见可以先检查响应头里的 Content-Encoding再决定要不要用resp.content手动解压。存储到 CSV 时的乱码和请求解码是两回事一个发生在读数据阶段一个发生在写文件阶段。如果你浏览器打开 CSV 中文乱码但控制台打印正常那就是写入编码的问题按之前说的方法把 open 函数的 encoding 改成utf-8-sig就能解决。5.4 数据量校验与结果质量检查爬完了不能直接收工必须做数据质量检查。我说的不是一个复杂流程而是几个简单的统计总共有多少个车系、多少个车型价格为空的有多少条车系名称重复的有多少条。这些统计用 pandas 几行代码就能完成import pandas as pd df pd.read_csv(car_prices.csv) print(总行数:, len(df)) print(价格缺失:, df[价格区间].isna().sum()) print(车系去重后数量:, df[车系名称].nunique())这种检查能暴露两类问题一是解析逻辑有遗漏导致大量字段为空二是重复抓取导致数据冗余。对于个人学习项目重复数据可以简单用 drop_duplicates 清理但更重要的是搞清楚是不是程序结构问题造成了重复。如果是运行时日志里能看到重复抓取的车系就需要回看循环逻辑检查是不是某个链接被反复访问。清洗后的数据如果准备交给 pandas 做分析我建议顺便把数据中的非数值字段检查一遍。比如价格区间字段9.89-13.99万可以拆成两个字段“最低参考价”和“最高参考价”用数值类型存储。这一步直接决定了后续做图表分析的便利程度。想把价格文本转成数值我习惯单独写一层 transform不要在原始读取阶段破坏原始字段保证 CSV 里既有原始文本又能生成可用于计算的派生列。6. 个人体会与后续扩展方向6.1 把爬虫项目当小工程来做收获更大整个项目跑完之后我最大的体会是爬虫的难点不在于“把数据抓下来”而在于“稳定、可控、可复用地把数据抓下来”。这个过程里最有价值的不是某个 XPath 写得精妙而是你建立了一套完整的思考方式——先分析结构再分层实现最后用日志和数据校验保证工程质量。我建议你把这个项目中的四个模块保存为独立文件形成一个迷你爬虫模板。以后遇到新需求分类信息网站爬虫、电商价格监控、资讯站点文章采集都可以复用这套骨架。你只需要重写解析函数、调整页面路径和字段映射几个小时就能出一个新项目。平时我自己也是这么积累的每做一次实战就把通用部分抽一次代码库慢慢就攒出了一套属于自己的“爬虫工具箱”。6.2 基于采集结果可以做哪些扩展爬到的数据最后落在 CSV 里这只是起点。我见过很多人爬完数据就搁置了其实这个数据集可以做很多事。最直接的扩展是数据可视化。用 pandas 读入数据按车系分组求平均指导价画出各车系的价格分布箱线图按排量分组统计车型数量做一个简单的条形图。这些图表做完哪怕只是 ppt 里展示也能直观反映出车型市场的分布规律。再加几个筛选条件你甚至能得出“15万左右的 SUV 里哪些车系选择最多”这类实用结论。更进阶一点你可以把爬虫和定时任务结合起来做成价格监控工具。每天固定时间运行一次把新车系的指导价和经销商参考价记录下来积累一段时间就能看到价格变化趋势。这时候断点续爬和增量存储的价值就体现出来了数据稳定累积不会重复抓取、不会丢数据。再往后如果想给项目加界面可以用 Flask 或者浏览器端框架做一个简单的 CRUD 应用把 CSV 里的数据导入数据库做筛选和搜索。不过这已经超出爬虫的范畴了。我的建议是先把爬虫这条链路做扎实数据积累得够多再来想应用层的事情顺序别搞反。6.3 最后一个值得留意的细节最后分享一个细节爬虫项目一定要把“从哪里拿到的数据”这个信息保留在字段里。我习惯在每行数据里带上数据来源 URL 和抓取时间戳。这样做有两个好处一是数据有问题时可以溯源知道是哪条链接带出来的方便排查二是后续做定时监控时能明确知道当前行是哪天、哪个时间点抓到的。这些字段看着不起眼实际使用时非常值钱。这个项目的源码结构其实非常简单算上配置文件也就五六个文件。但就是这五六个文件构成了一个“请求—解析—存储—调度—校验”的完整闭环。你能熟练写出并调整这套闭环也就意味着你已经告别“照着教程抄代码”的阶段真正具备了独立处理信息采集任务的能力。希望这篇记录能让你少踩几个坑也期待看到你自己扩展出来的版本。