简介:基于Python的二手车爬虫数据可视化分析设计是一份完整的毕业设计源码项目,适合计算机相关专业学生或爬虫、数据分析方向开发者参考。项目使用Selenium驱动谷歌浏览器采集二手车平台数据,将网页内容交给lxml的etree对象并借助XPath解析DOM树;对于价格、表显里程等关键字段采用的字体文件加密,代码中给出了应对处理思路。可视化部分基于pyecharts生成Echarts图表,数据写入MySQL及后续读取通过pymysql统一操作。资源共2000个文件,压缩包约53.73MB,以Python脚本为主(1745个py),同时包含HTML页面、JSON配置、PDF、JS/CSS/Markdown文档及依赖库相关C头文件等,目录结构清晰,便于定位源码、配置和文档。目前已有148人学习,整套项目覆盖从数据采集、加密字段处理、存储到可视化分析的实际流程,代码逻辑完整,环境配置与依赖关系清晰,既可用于毕业设计演示与答辩,也能作为二手车垂直领域爬虫与数据可视化的工程化范例。
1. 二手车爬虫数据可视化毕设:这套Python源码能做什么、值得做吗?
“基于Python的二手车爬虫数据可视化分析设计”这个毕业设计源码,本质上是一条完整的数据流水线:requests爬虫取数、pandas清洗、Flask+ECharts展示。第一次拿到它的人容易把精力全压在爬虫上,结果答辩时才发现清洗和可视化才是老师追问的重灾区。这套源码适合Python入门者、需要快速交稿的本科生,也适合想完整走一遍数据链路的人。它解决的问题很具体:从公开的二手车信息平台采集车源,清洗后回答“哪些品牌车源多、价格集中在哪个区间、车龄和价格怎么互相影响”,再把答案用可视化页面讲清楚。接下来我会按采集、清洗、避坑、可视化、验收的顺序,把可复现的代码和参数选择都写出来。
2. 爬虫选型与数据抓取:requests+xpath抓二手车列表的字段设计与翻页逻辑
二手车信息页目前大多还是服务端渲染,列表页的HTML里直接就有完整车源节点,不需要等异步请求。所以毕设最稳妥的组合就是requests做请求、lxml的xpath做解析,整条链路短,出问题也容易定位。选题时老师会问“为什么不用Selenium”,这一章先讲清楚选型理由,再给出字段设计和翻页代码。
2.1 为什么不用Selenium做主力爬虫:反爬成本与维护代价
很多新手看到页面里有动态效果就下意识上Selenium,实际上那个动态是鼠标悬浮或轮播图,车源列表本身是一次性返回的。Selenium最大的问题是启动浏览器慢,每翻一页都要重新加载渲染,抓几十页等得人想放弃;其次是无头浏览器的特征非常明显,window.navigator.webdriver、浏览器插件列表这些都可以被网站识别,反而更容易触发验证码。requests+xpath不用渲染页面,直接拿HTML文本,解析速度快,而且请求频率完全由自己控制。
我一般把Selenium定位成“兜底方案”,不是主力。只有当详情页的车况描述、上牌时间必须由JavaScript异步加载时,才单独用Selenium补抓那一个字段。做毕业设计不需要证明自己会用所有工具,需要证明的是“理解什么场景用什么工具”。requests+xpath在数据量几千条的场景下够用,而且代码短,答辩时讲起来不心虚。还有一个实际原因:Selenium需要在本地安装对应版本的浏览器驱动,很多同学的Windows环境里Chrome一更新驱动就失效,属于典型的“环境问题比代码问题更浪费时间”。所以我的建议是:先requests,被反爬挡住了再考虑Selenium,不要一上来就上重武器。
2.2 构造请求头与翻页参数:拿到干净列表数据的边界
抓列表页时,第一件事不是写解析,而是先检查返回的HTML里有没有车源节点。写一个fetch_list_page函数,把请求、翻页、解析拆开,这样每一页的失败不会影响整体。代码里要注意几个容易被忽略的参数:timeout不能省,resp.encoding要显式赋值,User-Agent和Referer要成对出现。
import time import random import requests from lxml import html def fetch_list_page(page_no=1, page_size=30): # 以某二手车信息平台为例,URL替换成你的目标源 url = "https://example.com/usedcar/list" 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", "Referer": "https://example.com/", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } params = { "page": page_no, "pagesize": page_size, } resp = requests.get(url, headers=headers, params=params, timeout=10) if resp.status_code != 200: print(f"请求失败: page={page_no}, status={resp.status_code}") return [] resp.encoding = resp.apparent_encoding root = html.fromstring(resp.text) # class属性可能包含多个值,用contains匹配更稳 items = root.xpath('//div[contains(@class,"car-item")]') cars = [] for item in items: title = item.xpath('.//a[contains(@class,"title")]/text()') price = item.xpath('.//span[contains(@class,"price")]/text()') link = item.xpath('.//a[contains(@class,"title")]/@href') cars.append({ "title": title[0].strip() if title else "", "price": price[0].strip() if price else "", "detail_url": link[0] if link else "", }) return cars # 抓前5页,样本量在几千条以内 all_cars = [] for page in range(1, 6): all_cars.extend(fetch_list_page(page_no=page)) time.sleep(random.uniform(1, 3)) print(f"共抓取 {len(all_cars)} 条车源")这段代码的核心是“把单页请求和循环动作分开”。fetch_list_page只负责一页,返回固定结构的list,外层循环决定抓多少页、多快抓。headers里的User-Agent和Referer必须成对出现,很多网站只校验Referer是否为空;timeout设10秒是为了防止某个页面卡住整个循环;resp.encoding = resp.apparent_encoding 是关键,直接用resp.text时如果站点声明的编码和实际编码不一致,中文就会变成乱码。xpath里用contains匹配class,是因为真实网页的class经常是“car-item hot”或“car-item focus”,绝对匹配会落空。
2.3 详情页补抓:车源编号、上牌时间与车况描述
列表页能拿到标题、价格和详情链接,但上牌时间、里程、车况描述这三个字段通常只在详情页里。补抓详情页时要注意两个问题:一是detail_url可能是相对路径,需要用urljoin补全;二是详情页字段不是每个都有,解析不到时不要无限重试,记录空值继续跑。下面这段代码在原有基础上增加详情补抓:
from urllib.parse import urljoin def fetch_detail(detail_url): 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", } resp = requests.get(detail_url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding root = html.fromstring(resp.text) plate_time = root.xpath('//div[contains(@class,"plate-time")]/text()') mileage = root.xpath('//span[contains(@class,"mileage")]/text()') desc = root.xpath('//div[contains(@class,"car-desc")]/text()') return { "plate_time": plate_time[0].strip() if plate_time else "", "mileage": mileage[0].strip() if mileage else "", "description": desc[0].strip() if desc else "", } for car in all_cars: if car.get("detail_url"): full_url = urljoin("https://example.com", car["detail_url"]) detail = fetch_detail(full_url) car["plate_time"] = detail["plate_time"] car["mileage"] = detail["mileage"] car["description"] = detail["description"] time.sleep(random.uniform(0.5, 1.5))urljoin的第二个参数如果是相对路径,会自动拼上第一个参数的协议、域名和路径。这里有个隐藏坑:部分平台的详情链接是/go/xxx?id=123这种跳转地址,不是真实页面,直接请求会302到首页。遇到这种情况,可以在列表页里找href里带数字id的完整链接,或者单独用正则从script变量里提出“carId”。补抓详情页的频率要比列表页更慢,因为详情页的请求特征更接近真实用户,连续快速请求容易被识别。抓完这一步,原始数据里应该已经有title、price、detail_url、plate_time、mileage、description六个字段,接下来交给pandas清洗。
3. 数据清洗与存储:把字符串价格和里程变成可统计分析的DataFrame
爬虫跑完只是第一步,真正决定分析质量的是数据清洗。二手车平台的价格字段非常自由,有的写“13.8万”,有的写“138000元”,还有的写“面议”;里程有“5.2万公里”也有“8000公里”;上牌时间有“2019年上牌”也有“2019.01”。这一章把清洗规则拆开讲,同时给出SQLite和CSV两种存储方案的取舍。
3.1 字段标准化:价格、里程、上牌年份的清洗规则
清洗的原则是“先看单位,再提数字”。网上很多教程直接用float(str),遇到“13.8万”直接报错或变成13.8,后面分析全废。我习惯写三个独立函数,分别处理价格、里程和年份,每个函数都只做一件事。价格统一转成“元”,里程统一转成“万公里”,年份提取四位数字。
import pandas as pd import re def clean_price(value): if not isinstance(value, str): return value value = value.replace("报价", "").replace("新车", "").strip() if "面议" in value or "暂无" in value: return None nums = re.findall(r"[\d.]+", value) if not nums: return None num = float(nums[0]) if "万" in value: num = num * 10000 return round(num, 2) def clean_mileage(value): if not isinstance(value, str): return value nums = re.findall(r"[\d.]+", value) if not nums: return 0.0 num = float(nums[0]) if "万" in value: return round(num, 2) return round(num / 10000, 3) def clean_year(value): nums = re.findall(r"20\d{2}", str(value)) return int(nums[0]) if nums else None df["price"] = df["price"].apply(clean_price) df["mileage"] = df["mileage"].apply(clean_mileage) df["year"] = df["plate_time"].apply(clean_year)正则在这里只负责“提取”,不做运算,避免直接用eval带来的注入风险。clean_price里先判断“万”再乘10000,这样“13.8万”会变成138000,“138000元”保持原样。clean_mileage里“8000公里”会被整除得到0.8万公里,散点图里不会因为单位不一致长出离谱的尾巴。clean_year只匹配20开头的四位年份,防止“2019款”这样的文本干扰。清洗后打印df.dtypes,price和year应该是数字类型,mileage也应该是float,如果有object就说明还有脏数据漏进来。
3.2 存储方案对比:SQLite vs CSV,毕设答辩怎么解释
存储是答辩里容易被追问的环节,最好先说清楚为什么不用MySQL。本地毕设的数据量也就几千到几万行,MySQL安装重、配置麻烦,SQLite单文件就能跑,而且Python内置支持,交作业时只需要带一个.db文件。CSV的好处是Excel能直接打开,但缺点是没有约束、中文编码容易乱。我的建议是两条路同时走:SQLite存全量明细数据,用于接口查询;CSV导出清洗后的分析子集,用于论文附件和图表绘制。
| 对比项 | SQLite | CSV |
|---|---|---|
| 查询能力 | 支持SQL聚合和条件过滤 | 需要全部读进pandas内存 |
| 数据约束 | 有主键和类型约束 | 无约束,容易混入脏值 |
| 编码问题 | 基本无乱码 | Excel打开需utf-8-sig |
| 答辩价值 | 能演示数据库设计 | 直观展示清洗结果 |
import sqlite3 conn = sqlite3.connect("car_data.db") df.to_sql("car_detail", conn, if_exists="replace", index=False) conn.close() # 导出Excel可正常打开的CSV df[["brand", "price", "mileage", "year"]].to_csv( "cleaned_car.csv", index=False, encoding="utf-8-sig" )to_sql里的if_exists="replace"适合每次重新爬取后覆盖整表,如果要做增量更新就改成append。导出CSV时编码用utf-8-sig而不是utf-8,这是血泪经验:Excel默认用GBK打开UTF-8文件,不带BOM就会乱码。很多同学到了答辩前才发现CSV打开是乱的,改一下编码就能解决。
3.3 去重与缺失值处理:保留有效样本的关键判断
车源平台有时会在搜索结果里重复展示同一台车,直接按标题去重会把不同城市的同款车误删,所以必须有唯一标识。详情页链接里的数字ID就是天然主键。处理缺失值时,价格缺失的样本直接删掉,因为可视化分析离不开价格;里程缺失用中位数填充,但要记录缺失率,论文里写“里程缺失比例约X%”会显得专业。
df["car_id"] = df["detail_url"].str.extract(r"(\d+)") print(f"去重前: {len(df)}") df = df.drop_duplicates(subset=["car_id"], keep="first") print(f"去重后: {len(df)}") df = df[df["price"].notna()] df["mileage"] = df["mileage"].fillna(df["mileage"].median()) df = df[df["year"].notna()]drop_duplicates的subset参数必须指向car_id,不能用title。keep="first"保留第一次抓到的记录,如果后面发现之前抓的数据有字段缺失,可以改成keep="last"重新覆盖。过滤掉价格缺失后,最后留下的样本量就是实际分析的有效样本。我习惯把这一步的样本量变化记录下来,答辩时能直接说出“从原始5000条清洗到有效4200条”,这个数字比任何图表都有说服力。
4. 二手车爬虫避坑指南:反爬、字段缺失与编码问题的排查方法
这一章整理的是抓取过程中最容易翻车的五个问题,每一条都按“现象、原因、解决”来写。做毕业设计的人遇到这些问题时最容易慌,其实基本都是同一个套路:打印响应内容、核对选择器、降低频率。
4.1 请求头不全导致返回空页面
现象:requests.get返回的是200,但xpath解析结果为空,打印resp.text发现是一段JS跳转代码或安全验证页。原因:只带了User-Agent,没带Referer,或者Referer和请求URL不是同源。很多平台会检查“这个请求是不是从本站页面点进来的”,Referer为空就会被拦。解决:把浏览器的Headers整体复制过来,至少包含User-Agent、Referer、Accept-Language、Accept。不要觉得UA万能,Referer不带上照样翻车。
4.2 XPath匹配不到任何节点
现象:浏览器开发者工具里复制的XPath能定位元素,但代码里返回空列表。原因:复制出来的绝对路径经常依赖tbody或层层嵌套的真实结构,而HTML解析时浏览器会自动补全标签,lxml不补;还有一种情况是目标数据根本不在HTML里,而是藏在script变量中,网络请求返回的HTML里只有一段JSON。解决:xpath写相对路径,优先用class属性定位;如果resp.text里搜不到关键词,改用正则从script里抽取数据。用正则提取JSON字符串再用json.loads解析,这是requests+xpath方案最重要的补充手段。
4.3 价格单位混用与里程数字格式不统一
现象:清洗后的price列最大值是几亿,mean明显偏高。原因:列表页里有的价格是“13.8万”,有的是“138000元”,正则只提取第一个数字时,“13.8万”被当成13.8,而不是138000。解决:在parse函数里先判断字符串是否包含“万”再决定要不要乘10000,不能对全列套用同一条规则。这个坑在清洗阶段几乎一定会遇到,不要指望一次写对,清洗后打印price.max()和price.min()检查是标准动作。
4.4 高频请求触发IP临时限制
现象:前5页抓得很顺利,到第8页开始返回403,或者出现“访问过于频繁”的提示。原因:请求间隔太短,而且time.sleep固定是2秒,规律性太强容易被行为识别。解决:把sleep改成random.uniform(1, 3),让相邻请求间隔有抖动;再写一个三次数值退避的重试逻辑,第一次失败等5秒,第二次等10秒,第三次放弃。毕设的数据量控制在几千条内,不需要追求单次抓完,分两次跑也不会影响分析。
4.5 网页编码乱码与emoji污染
现象:中文变成“锟斤拷”或车况描述尾部出现乱码符号。原因:站点页面声明的charset和实际编码不一致,或者车况描述里包含emoji,打印到控制台时终端编码不支持。解决:统一用resp.encoding = resp.apparent_encoding,让requests根据内容自动识别编码;如果识别错误,再手动指定GBK或GB18030。写入数据库时SQLite本身是UTF-8,不会出问题;导出CSV时用utf-8-sig。遇到emoji建议保留,ECharts可以正常显示,删掉反而丢信息。
5. Flask+ECharts数据可视化:把清洗结果变成毕业论文里的分析面板
可视化是这套设计最容易拿分的地方,但也是最容易写砸的地方。很多同学把图表堆在一起,老师问“这个图说明了什么问题”答不上来。正确的做法是“先定结论,再选图表”,每个图表都必须对应论文里的一句话。这一章从设计思路讲到接口返回,再到前端渲染,整条链路跑通后就是一个完整可展示的分析页面。
5.1 可视化设计思路:哪些图表能支撑“二手车市场分析”结论
毕设的分析页面不用做得很花哨,但图表和结论必须对应清楚。常见的问题是不知道选什么图表,其实可以从论文目录反推:写“品牌分布”就做柱状图,写“价格区间”就做饼图,写“车龄与价格关系”就做散点图。
| 图表 | 回答的问题 | 支撑的论文结论 |
|---|---|---|
| 品牌车源量Top10柱状图 | 哪些品牌流通量大 | 市场被头部品牌主导 |
| 价格区间分布饼图 | 主流预算集中在哪 | 10-20万是成交主力区间 |
| 车龄与价格散点图 | 车龄如何影响价格 | 3-5年车龄价格下降最快 |
| 热门车型平均价条形图 | 同级车型价格差异 | 品牌溢价明显 |
散点图的数据量要控制在200条左右,全量几千条会让页面渲染变卡,而且视觉上会变成一团黑点。Flask接口里可以限制返回量,这样前端不加代码也能流畅。图表颜色不要用默认的彩虹色,选一个主色加深浅变化,答辩投影时看得清楚。
5.2 Flask接口设计:按品牌、价格区间、年份返回聚合数据
Flask在这个方案里的角色是“数据服务层”,不做复杂业务逻辑。每个接口只做一件事:读取SQLite、聚合计算、返回JSON。这样前端只需要fetch就能拿到数据,不用写任何后端模板渲染。注意app.run里设置debug=False,debug模式会暴露本地路径和调试信息,答辩演示时万一报错也容易尴尬。
from flask import Flask, jsonify import pandas as pd import sqlite3 app = Flask(__name__) def load_cleaned_data(): conn = sqlite3.connect("car_data.db") df = pd.read_sql_query("SELECT * FROM car_detail", conn) conn.close() return df @app.route("/api/brand_top") def brand_top(): df = load_cleaned_data() top = df["brand"].value_counts().head(10) return jsonify({ "brands": top.index.tolist(), "counts": top.values.tolist() }) @app.route("/api/price_dist") def price_dist(): df = load_cleaned_data() bins = [0, 5, 10, 20, 30, 50, 100] labels = ["5万以下", "5-10万", "10-20万", "20-30万", "30-50万", "50万以上"] df["price_range"] = pd.cut( df["price"] / 10000, bins=bins, labels=labels, right=False ) dist = df["price_range"].value_counts().reindex(labels, fill_value=0) return jsonify({ "ranges": labels, "counts": dist.values.tolist() }) @app.route("/api/scatter") def scatter(): df = load_cleaned_data() sample = df[["year", "price"]].dropna().tail(200) return jsonify({ "years": sample["year"].tolist(), "prices": sample["price"].tolist() }) if __name__ == "__main__": app.run(debug=False)价格接口里先把price从元转成万元再分箱,这样5万以下的区间才有实际意义。pd.cut的right=False表示左闭右开,5万会被归到“5-10万”而不是“5万以下”。value_counts之后用reindex补空区间,防止某个区间没有样本时,返回的数组长度和图表的labels对不上。散点接口用tail(200)取最后200条,这里不是随机抽样,而是取采集顺序末尾的数据;如果你希望更科学,改成df.sample(n=200, random_state=42)就行。
5.3 ECharts前端接入:图表渲染与参数联动
前端页面保持简洁,一个HTML文件里放一个图表容器,JavaScript只负责fetch接口和setOption。ECharts的脚本文件放在static目录下,不要用CDN,因为答辩现场的网络环境不一定稳定。下面以品牌柱状图为例:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>二手车数据可视化</title> <script src="/static/echarts.min.js"></script> </head> <body> <div id="brand_chart" style="width: 800px; height: 480px;"></div> <script> fetch('/api/brand_top') .then(res => res.json()) .then(data => { var chart = echarts.init(document.getElementById('brand_chart')); chart.setOption({ title: { text: '品牌车源量Top10' }, tooltip: {}, xAxis: { type: 'category', data: data.brands }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: data.counts, itemStyle: { color: '#2c7fb8' } }] }); }) .catch(err => console.error('加载图表失败', err)); </script> </body> </html>fetch的then链里,data.brands和data.counts必须和后端接口返回的key名字完全一致。ECharts5的写法是初始化时不传option,后面用setOption,这样后续联动时只需要重新setOption。如果页面里有多个图表,每个图表都对应一个独立id的div和独立的初始化变量。图表渲染完成后,用浏览器开发者工具的Network面板检查接口状态码,如果某个接口返回500,优先看Flask控制台的报错堆栈。
6. 进阶与验证:用describe和交叉核对提升答辩说服力
抓完数据别急着做图表,先做两件事。第一件是打印price的describe,第二件是随机抽10条去源站人工核对,这两步能让你的数据结论经得起追问。
print(df["price"].describe()) # 随机抽10条人工核对 sample = df.sample(n=10, random_state=42) print(sample[["title", "price", "mileage", "year"]].to_string())describe里的count、mean、min、max一眼能看出清洗有没有问题。比如min是1元,说明有“面议”字段被误解析成了1;max如果是99999999,说明有“万元”没乘对。中位数和均值偏离大时也要警惕,正常二手车价格分布应该是右偏,均值偏大但中位数合理是正常的,如果均值反而小于中位数就要回头查异常值。人工核对时不用全查,随机10条就够了,但要把核对结果截图放进论文附录,这比空口说“数据可靠”有说服力得多。
如果还想再加一个进阶方向,可以做“同车型不同年份的价格走势”。做法是先选一款车源量大的车型,比如雅阁或朗逸,然后按年份分组求价格中位数,用折线图展示。这个图表回答的是“保值率如何随车龄变化”,比静态柱状图更接近企业级数据可视化要做的事。实现起来也不难,在Flask接口里加一个groupby(year).price.median()就行,前端换一条折线series。做这个方向时要控制数据范围,只选车源量top3的车型,否则样本太少会让折线忽高忽低。我自己的习惯是每一次清洗后先看一眼describe,再决定要不要继续往下做,这个习惯救过我很多次。希望帮到你。
本文还有配套的精品资源,点击获取