ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

汽车之家报价爬虫实战:requests+lxml采集车型数据与XPath解析

汽车之家报价爬虫实战:requests+lxml采集车型数据与XPath解析 做爬虫的都知道报价类网站是练习反爬对抗的好靶场也是新手最容易翻车的地方。汽车之家作为国内头部汽车垂直网站车型数据全、更新快但它的页面结构和反爬逻辑也一直在变。这个项目我完整做了一遍从车型库列表页开始一路爬到车系报价详情页用requests lxml实现提取车型名称、指导价等信息最后落到 CSV 和 SQLite 两份存储里。整个过程涉及 URL 规律分析、XPath 定位、请求头伪装、频率控制、异常重试和编码处理基本覆盖了单机爬虫开发会遇到的主要问题。文章会先讲清楚为什么这样选型和设计再逐段解读核心代码最后把我实际踩过的 403、解析为空、乱码这类坑的排查过程完整写出来。适合有 Python 基础、想完整走通一次爬虫实战全流程的读者。1. 项目边界与数据目标动手之前先确认要什么爬虫项目的第一个问题从来不是怎么写代码而是到底要抓什么、从哪抓、抓到什么程度。这个项目我一开始也犯过直接开干的错误结果抓了一堆无用字段解析逻辑越写越乱。后来老老实实先把数据边界画清楚整个工程就顺畅多了。1.1 要采集的信息与页面分布汽车之家的数据层级大概是车系 - 具体车型 - 参数/报价三层。车系可以理解成奥迪A4L这种车型系列具体车型则是2024款 40 TFSI 时尚动感型这种带年款和配置的细分条目。我这次的核心目标定了三个字段车系名称用于分类比如奥迪A4L车型名称具体到年款和配置比如2024款 40 TFSI 时尚动感型指导价厂商公布的统一售价页面里通常以万元为单位展示没有把经销商报价作为第一版目标是因为经销商报价往往需要切换城市、由异步接口动态返回代码复杂度会明显上升。爬虫开发的常识就是第一版先把主干跑通把数据结构和存储设计好后续再扩展字段。一口吃成胖子最后往往是连主干都跑不动。1.2 为什么选 requests lxml 而不是一上来就 Scrapy很多教程一上来就让你建 Scrapy 工程Item、Pipeline、Middleware 层层封装新手还没碰到目标数据就先被框架本身搞晕了。这个项目的数据量不大、目标明确用requests lxml是最直接的组合requests 负责 HTTP 请求会话保持、超时和重试逻辑直观lxml 的 XPath 解析效率高用text()函数取文本非常顺手整个工程可以用一个单文件脚本写完调试时哪里出错一目了然。Scrapy 当然是更强大的框架但它的优势在分布式、管道化和大规模调度。等你确定我就是要长期批量采集海量数据的时候再迁移过去完全不迟。这也符合项目开发的普遍规律先跑通再优化别在第一步就给代码注入太多复杂度。1.3 合规边界robots、频率和用途爬虫本身是中性技术但对目标网站要有底线。我给自己定的原则是只访问 robots.txt 允许或未明确禁止的公开页面不碰任何需要登录后才能看到的数据不提交表单、不触发写操作整个采集过程只有 GET 请求请求间隔至少 2 秒以上避免对目标站点造成压力采集结果仅用于个人学习研究不用于商业用途不批量公开转发。如果不把频率自律当成项目的一部分来设计你会发现爬虫跑不了几次就被网站拒绝然后开始到处找代理、换 IP陷入无休止的对抗。真正能长期稳定运行的采集程序代码只占一半另一半是克制。提示建议先查看目标站点的 robots.txt 和公开声明确认数据使用边界再做采集。爬虫能力和责任心是配套的。2. 页面结构与 XPath 定位找到 URL 规律和数据入口写爬虫其实就是把人在浏览器里的操作翻译成代码。人点链接、看页面、抄数据代码就对应着发请求、解析 HTML、提取字段。所以第二步必须搞清楚目标页面的结构和 URL 规律。2.1 URL 规律从列表页到详情页的关键跳转从车型库入口进入站点页面会按品牌首字母分组展示车系。我们需要的是每个车系链接里的 ID后面拼接详情页地址全靠它。我当时整理出来的 URL 规律大致如下页面类型URL 形态用途车系列表页https://www.autohome.com.cn/car/...获取车系名称和链接入口车系参数页https://car.autohome.com.cn/config/series-{车系ID}.html获取详细技术参数车系报价页https://car.autohome.com.cn/price/series-{车系ID}.html获取车型名称和价格车系 ID 是个纯数字编号一个车系对应一个 ID而一个车系下面会挂多个具体车型。这个一对多的关系决定了爬虫的循环结构先遍历列表页拿所有车系 ID再逐个进入详情页解析具体车型。提醒一点不同时间点 URL 规则可能变化最可靠的方式是在浏览器里从列表页点进详情页观察地址栏的变化规律把映射关系记下来再写代码。不要凭记忆写死 URL那是最容易翻车的地方。2.2 XPath 定位中的 text() 函数拿到 HTML 之后解析方式我首选 XPath。理由有两个一是基于 class 和结构的相对路径比浏览器复制的绝对路径稳定得多二是 lxml 的 XPath 解析速度足够快代码写起来也简洁。text()是 XPath 里最常用的函数之一它用于选取节点的直接文本子节点。比如name item.xpath(.//a[classseries-name]/text())这行代码会把a classseries-name奥迪A4L/a里的奥迪A4L取出来。text()只返回文本部分不返回标签配合 Python 的strip()方法清洗首尾空格特别好用。但text()有个容易踩的坑如果文本被嵌在子标签里比如a奥迪span新款/span/a直接//a/text()只会得到奥迪后面的新款取不到。这时候要改用string(.)name item.xpath(string(.//a))string()会递归拼接节点下所有文本把奥迪新款完整取出来。这个差异在实际解析中经常遇到尤其车型名称里带2024款这种修饰词的时候用错函数会导致数据不完整。2.3 判断页面是否正常返回解析代码之前先确认拿到的确实是正常页面而不是反爬提示页。我总结了三板斧状态码 403 或 302 跳转大概率被请求风控拦下了返回 HTML 里没有预期节点反而出现验证或访问异常关键词可能进入了风险验证页页面 title 异常或者内容长度突然变得很短多半是临时限制。在代码里我会加一个前置检查逻辑解析前先确认页面里有没有目标容器。这个习惯能省掉大量无脑调试时间。毕竟解析写错了和根本没拿到页面排查方向完全不一样。3. 核心代码实现请求封装、解析与主流程调度这一章给出完整的可运行源码然后逐段说明设计意图。代码里的选择器是以我采集时看到的页面结构写的你实际运行时如果发现解析结果为空先用浏览器 F12 确认当前页面结构再调整 XPath。3.1 请求封装UA 池、超时、重试请求层是整个爬虫的地基。我看过很多新手代码直接requests.get(url)一把梭遇到 403 就懵。这里做了三层防护UA 轮换、超时设置、失败重试。import random import re import time import requests from lxml import etree UA_LIST [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 Edg/118.0.2088.76, ] BASE_HEADERS { 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, } def get_page(url, timeout10, retry3): 请求页面带UA轮换、超时和重试 for attempt in range(1, retry 1): try: headers BASE_HEADERS.copy() headers[User-Agent] random.choice(UA_LIST) resp requests.get(url, headersheaders, timeouttimeout) if resp.status_code 200: resp.encoding resp.apparent_encoding or utf-8 return resp.text elif resp.status_code 403: print(f[{attempt}] 403 被拒等待后重试: {url}) else: print(f[{attempt}] HTTP {resp.status_code}: {url}) except requests.RequestException as exc: print(f[{attempt}] 请求异常: {exc}) time.sleep(random.uniform(2, 4)) return None为什么用random.choice(UA_LIST)而不是固定一个 UA因为单一 UA 在持续请求时特征太明显。为什么resp.encoding要用apparent_encoding因为某些页面返回的 charset 声明和实际编码不一致不用这个字段很容易出现乱码。这些细节单独看都不起眼叠加起来就是稳定性和不稳定的差别。3.2 列表页解析提取车系链接和 ID拿到列表页 HTML 后要做两件事提取车系名称提取车系 ID。车系 ID 藏在链接里用正则抽出来。def extract_series_links(html): 从列表页提取车系链接和车系ID if not html: return [] tree etree.HTML(html) links tree.xpath(//a[contains(href, series)]/href) names tree.xpath(//a[contains(href, series)]/text()) result [] for href, name in zip(links, names): series_id re.search(rseries[_-]?(\d), href) if series_id: result.append({ series_id: series_id.group(1), series_name: re.sub(r\s, , name), }) seen set() uniq [] for item in result: if item[series_id] not in seen: seen.add(item[series_id]) uniq.append(item) return uniqcontains(href, series)是一个相对宽松的匹配写法好处是能把所有指向车系的链接都捞出来坏处是可能混入一些干扰项。所以去重是必须的同一车系在列表页可能出现在多个分组里。re.sub(r\s, , name)是清洗名称里的换行和空格这类隐藏在 HTML 标签间的空白字符如果不处理存进数据库后你会花大量时间在数据清洗上。3.3 报价页解析提取车型名称和指导价车系详情页的核心数据结构是表格每一行是一个具体车型第一列是车型名称第二列是价格。def parse_series_price(html, series_name): 从车系报价页提取车型名称与指导价 if not html: return [] tree etree.HTML(html) rows tree.xpath(//table[contains(class, price-list)]/tbody/tr) data [] for row in rows: name row.xpath(string(.//td[1])).strip() price_text row.xpath(string(.//td[2])).strip() price_numbers re.findall(r\d\.?\d*, price_text) price_low price_numbers[0] if price_numbers else if name: data.append({ series_name: series_name, model_name: name, guide_price: price_low, }) return data这里有几个关键决策。第一用string(.//td[1])而不是td[1]/text()因为车型名称内部很可能有子标签string()能把完整文本拼接出来。第二价格字段用re.findall(r\d\.?\d*, price_text)提取数字而不是直接正则替换因为页面上价格经常是12.98-17.68万这种区间形式只取第一个数字作为入门指导价。第三如果页面里某个车型没有报价数字列表为空就填空字符串而不是报错中断。3.4 主流程调度循环、延时与异常保护最后把所有函数串联起来。主流程的逻辑是请求列表页 - 提取车系列表演示取前 20 个- 逐个请求报价页 - 解析 - 累计结果 - 存储。def main(): list_url https://www.autohome.com.cn/car/0_1-0-0-0-0-0-0-0-0-0-0-0-0-0-0-0-1.html page get_page(list_url) series_list extract_series_links(page) print(f共发现 {len(series_list)} 个车系) all_data [] # 演示只取前 20 个实际使用时可以去掉切片 for series in series_list[:20]: detail_url fhttps://car.autohome.com.cn/price/series-{series[series_id]}.html html get_page(detail_url) rows parse_series_price(html, series[series_name]) all_data.extend(rows) print(f{series[series_name]}: {len(rows)} 条) # 请求间隔随机 2~5 秒降低对目标站点的压力 time.sleep(random.uniform(2, 5)) save_to_csv(all_data) save_to_sqlite(all_data) print(f采集完成共 {len(all_data)} 条数据) if __name__ __main__: main()主循环里有几个容易被忽视的细节。第一个是所有异常都在get_page内部处理了主循环不需要再套一层 try-except代码干净很多。第二个是time.sleep(random.uniform(2, 5))放在每次请求详情页之后而不是循环开始之前这样即使某个请求失败提前返回了下一次请求也会等足够久。4. 数据落地CSV 与 SQLite 两种存储方案的取舍数据解析出来只是第一步怎么存、存哪里直接决定后续怎么用。这章把 CSV 和 SQLite 两种方案的代码、适用场景和坑都讲清楚。4.1 CSV快速验证结果时的首选def save_to_csv(data, filenamecar_prices.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[series_name, model_name, guide_price]) writer.writeheader() writer.writerows(data)newline这个参数很多人不知道不加的话在 Windows 上写出的 CSV 每行之间会多一个空行。encodingutf-8-sig是专门为了让 Excel 能正确识别中文设计的普通utf-8编码写出的文件Excel 直接打开会出现中文乱码而utf-8-sig在文件开头加了 BOM 标记Excel 就能认出来。CSV 的优点和缺点一样明显。优点是可以直接打开看、轻量、不依赖数据库环境缺点是数据量一大就难查、难去重、难做增量更新。所以 CSV 适合开发阶段快速验证解析结果不适合作为长期存储方案。4.2 SQLite支持去重和增量更新的轻量数据库import sqlite3 def save_to_sqlite(data, db_namecar_prices.db): conn sqlite3.connect(db_name) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS car_price ( series_name TEXT, model_name TEXT, guide_price TEXT, PRIMARY KEY (series_name, model_name) ) ) for item in data: cursor.execute( INSERT OR REPLACE INTO car_price (series_name, model_name, guide_price) VALUES (?, ?, ?), (item[series_name], item[model_name], item[guide_price]), ) conn.commit() conn.close()SQLite 是 Python 自带的标准库不需要安装任何东西数据存在一个.db文件里非常适合爬虫的落地存储。这里用(series_name, model_name)作为联合主键配合INSERT OR REPLACE实现了一个天然的去重逻辑同一个车系的同一个车型第二次采集时不会产生重复记录而是直接覆盖。维度CSVSQLite上手成本极低直接打开即用需要写一点点 SQL去重能力需要自己写逻辑主键 INSERT OR REPLACE数据量级适合几千条演示可以支撑百万级扩展性弱可以后续加字段、加表4.3 中文编码易错点汇总爬虫落地存储遇到最多的问题就是编码。我整理了三类最常见的情况报错/现象原因处理UnicodeEncodeError: gbk codec...Windows 控制台默认 gbk 编码输出前统一转 utf-8或设置PYTHONIOENCODINGutf-8CSV 被 Excel 打开中文乱码用了普通utf-8改用utf-8-sig页面文本本身是乱码resp.encoding推断错误用resp.apparent_encoding手动覆盖请求阶段的编码问题同样重要。某些页面声明的 charset 和实际内容不符如果直接按声明的编码解码解析出来就是乱码。resp.apparent_encoding会基于内容实际字节来推测编码准确率高很多。5. 实战踩坑记录403、解析为空、乱码的完整排查链路爬虫项目里最珍贵的经验不是成功的代码而是踩坑后积累的排查思路。这一章我把实际遇到过的三个典型问题完整复盘一遍。5.1 403 的完整排查链路第一次跑这个采集器第一行请求就直接 403。我的排查过程是这样的先做最小化测试curl命令直接访问目标页面发现能正常返回。这说明网站没有封 IP问题出在我代码里的请求构造。默认 UA 是最常见的原因。requests 默认的python-requests/x.x.x太显眼了任何服务端都能一眼识别。换成一个 Chrome 的 UA 后能访问了。但跑了十几个页面后又开始 403。这次排查发现单纯换 UA 不够还要有Accept-Language和Referer。服务端看重的是这个请求像不像真人浏览请求头越完整特征越正常。补全请求头后能连续采集了。但为了保险我仍然保留了随机延时。现象可能原因处理第一个请求就 403requests 默认 UA 被识别配置完整 UA 和请求头跑了几十个请求后 403请求频率过高增加随机延时 2~5 秒偶发 403 后恢复服务端限流策略重试机制 降低频率这个排查过程给我的经验是反爬大多不是单一因素而是请求头、频率、行为特征组合判断的结果。遇到 403先别急着上代理按请求头完整性 - 请求频率 - 行为特征的顺序排查90% 的问题都能解决。5.2 XPath 解析为空页面结构与懒加载的坑解析结果为空是另一个高频问题而且比 403 更让人头疼因为请求是成功的、状态码是 200但 XPath 就是查不到数据。我遇到这种情况的排查顺序先把拿到的 HTML 存到本地文件用浏览器打开肉眼确认页面内容是否存在。检查contains(class, price-list)这个条件网站改版后 class 名称变了是常有的事。页面结构只要调整一次写死的选择器就会全部失效。确认目标内容是不是在 iframe 子框架里。iframe 里的内容不会出现在主文档 HTML 中需要单独请求 iframe 的 src 地址。确认目标内容是不是懒加载。部分报价信息是页面加载后再异步请求接口获取的直接请求 HTML 拿不到需要找到背后的数据接口。提示爬虫踩坑的通用调试三板斧先保存 HTML 到本地肉眼确认再缩小 XPath 范围最后确认内容是不是异步加载。不要盯着代码猜最笨的存文件打开看往往是最快的。5.3 频率限制请求越多不一定越快我试过年头把延时从 2 秒降到 0.5 秒结果跑了没几分钟就被限制访问得等好一阵子才能恢复。算总账的话反而是规规矩矩用 2~5 秒延时稳定地跑完所有数据更快。后来我给程序加了一个简单的进度日志每完成一个车系就打印一条记录包括当前进度、耗时、成功条数。这样即使中途被限制也能知道断在哪里恢复后从断点继续。6. 优化方向从能跑到好用如何走下一步爬虫跑通只是起点。这一章说的是等你确认数据价值和稳定性之后可以怎么往更专业的方向演进。6.1 协程提速的取舍requests 是同步请求一次只能等一个响应。如果数据量上万可以考虑用aiohttpasyncio做并发请求。但提速从来不是免费的并发请求对目标站点的压力会成倍增加被封的概率也随之上升。我的建议是先评估数据量是否有必要提速再评估你是否准备好应对更高强度的反爬对抗。如果只是日常学习或小批量采集保持同步 随机延时是性价比最高的方案。6.2 断点续爬设计长时间运行的爬虫一定会遇到中断网络断、程序异常、电脑重启。一个简单的断点续爬方案是每处理完一个车系就把它的 ID 写入一个progress.txt文件。恢复运行时先加载这个文件跳过已处理的车系。def load_progress(filenameprogress.txt): try: with open(filename, r, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) except FileNotFoundError: return set()这样即使跑了 1000 个车系后中断恢复时也只是多花几秒钟读取文件而不是重新跑一遍全部数据。6.3 可视化与后续扩展方向数据采集完只是第一步如果想让结果更有价值可以考虑以下几个方向用 Streamlit 或 Flask 做一个简单的报价查询页面按品牌筛选、按价格区间筛选数据就能变成可交互的工具把采集任务交给定时调度每天增量更新价格变动形成价格走势分析的基础数据在数据量真正达到百万级、需要多台机器协作时再引入分布式采集框架配合消息队列做任务分发。前端防爬、字体反爬、接口参数签名这些话题是爬虫进阶路上绕不开的知识但它们的核心价值是帮助你理解网站为什么这样设计而不是教你对抗任何合法限制。理解了原理之后你才能做出更合理的采集决策。最后说一点自己的体会。爬虫写多了你会发现代码只占一半另一半是对目标站点的理解和对访问节奏的控制。跑通这个采集器之后我做的第一件事不是加功能而是把请求间隔调大、给输出文件加上时间戳、把日志打清楚。因为爬虫跑起来很容易维护它不惹麻烦才难。这篇文章里的完整代码你可以直接拿去改把选择器替换成目标网站的实际结构把存储换成自己习惯的格式跑的时候记得带上延时和异常处理。希望这篇实战记录能帮你少踩几个坑。
返回列表