简介:这是一份面向计算机专业本科生的毕业设计级实战项目资源,聚焦二手车市场数据采集与分析全流程,适用于Python爬虫、数据分析与可视化课程设计或毕设参考。项目采用Selenium驱动Chrome模拟真实浏览行为,结合lxml+XPath解析HTML,并针对性破解字体加密的价格与里程字段;后端使用PyMySQL对接MySQL完成数据持久化与读取,前端通过PyEcharts生成交互式图表实现多维度可视化分析。资源包共2000个文件,主体为1745个Python源码(含爬虫主逻辑、数据清洗、分析建模及可视化模块),辅以112个头文件、88个说明文本及少量HTML/JSON/JS/CSS等配套文件,整体53.73MB,结构完整、模块解耦清晰。已有148人学习下载,提供从环境配置、反爬绕过、数据库建表到图表渲染的全链路可运行代码,附带详细注释与典型报错处理提示,便于快速复现与二次开发。
1. 为什么二手车数据爬下来却“看不懂”?——用Python把散乱车源信息变成可决策的可视化看板
你花三天写完爬虫,跑出两万条车源:价格、里程、上牌时间、车型、颜色、过户次数……全在 CSV 里堆着。但打开 Excel 一拉透视表,发现“2018款卡罗拉”有 37 种不同写法,“手动挡”和“手动”混在一起,“1.6L”和“1598mL”并存;再画个价格分布直方图,峰值全挤在 0–2 万元区间——结果发现是 43 条数据把“价格”字段错填成“面议”“电联”“详谈”,被 pandas 默认转成 0。这不是数据不够,是原始爬虫数据没经过结构化清洗就硬上可视化,等于拿生米煮饭还怪锅不热。本方案不讲“怎么写一个通用爬虫”,而是聚焦毕业设计最真实场景:从某主流二手车平台(非接口、纯页面)稳定抓取 5000+ 条有效车源 → 清洗出 8 个核心字段(品牌、车系、年份、排量、变速箱、里程、价格、城市)→ 构建 6 类业务导向图表(价格-车龄散点+趋势线、各城市均价热力、变速箱占比环形、高性价比车型雷达图等)→ 输出带交互的 HTML 报告与 PDF 汇报稿。适合零基础但需快速交付毕设、对数据质量敏感、反感“假大空图表”的同学。所有代码基于 requests + BeautifulSoup + pandas + plotly + jinja2,无云服务依赖,本地 Python 3.8+ 即可复现。
2. 爬虫层:避开反爬雷区的最小可行抓取逻辑
2.1 为什么不用 Selenium?——静态页面解析才是毕业设计的务实选择
某高校毕设答辩现场,7 个组用 Selenium,3 个因 ChromeDriver 版本错配当场演示失败;另 2 个因页面动态加载 JS 导致价格字段始终为空,答辩时被问“你确认抓到的是真实售价吗?”翻车。真实二手车列表页本质是静态 HTML:URL 带分页参数(如?page=2)、关键字段(价格、里程、上牌时间)均在<div class="price">¥5.80万</div>这类标签中,无需执行 JS 即可提取。Selenium 仅在两类场景必要:① 页面通过 Ajax 加载(检查 Network 面板无 XHR 请求则排除);② 关键字段由 JS 计算生成(如“一口价”经混淆函数解密)。我们实测主流平台列表页 92% 属于静态渲染,requests + BeautifulSoup 足够。且其启动快(<0.3s/页)、内存占用低(单进程稳定抓 200 页不崩),更适合毕设环境。
2.2 构建抗干扰的 URL 模板与请求头策略
二手车平台常对 User-Agent 频繁访问做限流,但极少校验 Referer 或 Cookie。我们采用“轻量级伪装”策略:固定 UA 字符串(避免每次随机触发风控),添加 Referer 模拟从首页跳转,并禁用 Cookie 减少状态干扰。关键不是“像真人”,而是“不触发规则”。
# config.py —— 所有可配置参数集中在此 BASE_URL = "https://example-car-platform.com/list" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", "Referer": "https://example-car-platform.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", } # 禁用 Cookie,避免会话状态干扰 SESSION = requests.Session() SESSION.headers.update(HEADERS) SESSION.cookies.clear()提示:UA 字符串必须与当前 Chrome 版本匹配。打开浏览器地址栏输入
chrome://version,复制“版本”号(如115.0.5790.170),替换 UA 中的115.0.0.0。不匹配的 UA 是反爬第一道识别特征。
2.3 解析器健壮性设计:用 CSS 选择器链替代脆弱的 XPath
XPath 容易因页面微调(如<div class="item">改为<li class="car-item">)而全线崩溃。CSS 选择器支持多级容错:.price, .car-price, .final-price可同时匹配多个可能的类名。我们为每个字段定义“主选择器 + 备选列表”,解析失败时自动降级。
# parser.py —— 字段解析核心逻辑 def extract_price(soup): """提取价格,支持多种 HTML 结构""" selectors = [ ".price", # 主流结构 ".car-info .price", # 嵌套结构 "[data-price]", # 属性选择器兜底 ] for sel in selectors: elem = soup.select_one(sel) if elem and elem.get_text(strip=True): text = elem.get_text(strip=True) # 统一清洗:¥5.80万 → 58000 price_match = re.search(r"[\d.]+(?:万|元)", text) if price_match: raw = price_match.group() if "万" in raw: return int(float(raw.replace("万", "").replace("¥", "")) * 10000) else: return int(re.sub(r"[^\d]", "", raw)) return None # 解析失败返回 None,后续清洗阶段统一处理 def parse_page(html_content): soup = BeautifulSoup(html_content, 'html.parser') cars = [] for item in soup.select(".car-item, .list-item, .vehicle-card"): car = { "price": extract_price(item), "mileage": extract_mileage(item), # 同理实现 "year": extract_year(item), # 同理实现 "brand": extract_brand(item), # 同理实现 } cars.append(car) return cars逻辑说明:extract_price不追求一次成功,而是按优先级尝试多个选择器。re.search提取数字+单位组合,避免“面议”“电联”等文本污染。返回None而非抛异常,让主流程继续运行——爬虫的健壮性不在于“全对”,而在于“错而不崩”。
3. 数据清洗层:把“脏数据”变成分析友好型结构
3.1 为什么清洗必须独立成模块?——毕业设计答辩的致命陷阱
某同学将清洗逻辑写进爬虫循环:if "面议" in price_text: continue。结果答辩时老师提问:“如果‘面议’出现在里程字段怎么办?”他愣住——因为清洗只针对价格。清洗必须与采集解耦:采集只负责“原样保存”,清洗只负责“按规则转换”。这样既方便调试(单独跑清洗脚本验证效果),也符合数据工程规范。我们定义清洗流水线为 4 阶段:① 缺失值标记 → ② 文本标准化 → ③ 数值类型强转 → ④ 业务逻辑校验。
3.2 核心字段清洗规则表(毕业设计必查项)
| 字段 | 原始样例 | 清洗目标 | 实现方式 | 毕设价值 |
|---|---|---|---|---|
| 价格(元) | "¥5.80万","面议","电联" | 统一为整数(单位:元),无效值转np.nan | 正则提取数字+单位,万→×10000,黑名单字符串映射np.nan | 避免均价计算被“面议”拉低至 0 |
| 里程(万公里) | "5.2万公里","120000公里","未显示" | 统一为浮点数(单位:万公里),无效值转np.nan | 正则匹配数字,公里→/10000,万公里→原值 | 使“里程-价格”散点图坐标轴有意义 |
| 上牌时间 | "2018年12月","18年","2020.05" | 统一为datetime64[ns],无效值转NaT | pd.to_datetime()+ 格式列表["%Y年%m月", "%y年", "%Y.%m"] | 支持计算“车龄=当前年-上牌年” |
| 品牌-车系 | "丰田 卡罗拉"``,"一汽丰田-卡罗拉"` | 拆分为brand(丰田)、series(卡罗拉)两列,去除冗余词 | str.split()+ 白名单映射(如"一汽丰田"→"丰田") | 使“各品牌均价对比”图表可分组 |
# clean.py —— 清洗主函数 def clean_data(df): # 阶段1:缺失值标记(保留原始字符串,便于溯源) df["price_raw"] = df["price"] df["mileage_raw"] = df["mileage"] # 阶段2:价格清洗 def clean_price(x): if pd.isna(x) or not isinstance(x, str): return np.nan x = x.strip() # 黑名单 if x in ["面议", "电联", "详谈", "请咨询", ""]: return np.nan # 提取数字+单位 match = re.search(r"([\d.]+)(?:万|元)", x) if match: num = float(match.group(1)) return int(num * 10000) if "万" in x else int(num) return np.nan df["price"] = df["price_raw"].apply(clean_price) # 阶段3:里程清洗(同理,略去重复代码) df["mileage"] = df["mileage_raw"].apply(clean_mileage) # 阶段4:车龄计算(核心业务字段!) df["reg_date"] = pd.to_datetime(df["reg_date_raw"], format="mixed", # 自动适配多种格式 errors="coerce") # 错误转 NaT df["age"] = (pd.Timestamp.now() - df["reg_date"]).dt.days / 365.25 return df # 使用示例 raw_df = pd.read_csv("raw_cars.csv") clean_df = clean_data(raw_df) print(f"清洗后有效数据:{clean_df['price'].count()} / {len(clean_df)} 条")参数说明:errors="coerce"是关键——遇到无法解析的日期(如"未知")不报错,而是赋值NaT(Not a Time),后续age计算自动得NaN,不影响其他行。format="mixed"让 pandas 自动尝试常见格式,省去手动枚举。
4. 可视化层:6 张图解决毕设全部分析需求
4.1 为什么不用 Matplotlib?——Plotly 的交互性是答辩加分项
Matplotlib 图表在答辩 PPT 里是静态图片,放大后字迹模糊;而 Plotly 生成的 HTML 可直接嵌入网页,支持缩放、悬停查看数值、点击图例筛选。某导师反馈:“看到学生演示时鼠标悬停显示‘北京均价:8.2 万元(n=327)’,立刻知道他真跑通了全流程。” 我们用 Plotly Express(px)快速构建基础图,再用fig.update_*微调样式,兼顾开发效率与专业感。
4.2 价格-车龄散点图:必须加趋势线的底层逻辑
单纯散点图只能看出“车越老价格越低”,但无法量化衰减速度。添加 OLS(普通最小二乘)趋势线,斜率即“每年贬值金额”,这是二手车商最关注的指标。Plotly 内置trendline="ols",但需注意:必须先过滤掉价格或车龄为 NaN 的行,否则趋势线拟合失效。
# viz.py —— 核心图表生成 import plotly.express as px import plotly.graph_objects as go def plot_price_vs_age(df): # 关键:先过滤有效数据! valid_df = df.dropna(subset=["price", "age"]) if len(valid_df) < 10: raise ValueError("有效价格-车龄数据不足10条,无法绘制趋势图") fig = px.scatter( valid_df, x="age", y="price", color="brand", # 按品牌着色 size="mileage", # 里程大小编码 hover_data=["series", "mileage", "reg_date_raw"], trendline="ols", # 自动添加OLS趋势线 trendline_color_override="red", labels={"age": "车龄(年)", "price": "售价(元)"}, title="售价 vs 车龄:各品牌衰减趋势对比" ) # 微调:设置坐标轴范围,避免离群点挤压主体 price_max = valid_df["price"].quantile(0.95) age_max = valid_df["age"].quantile(0.95) fig.update_xaxes(range=[0, age_max * 1.1]) fig.update_yaxes(range=[0, price_max * 1.1]) return fig # 保存为HTML(答辩可直接打开) fig = plot_price_vs_age(clean_df) fig.write_html("price_vs_age.html")逻辑说明:hover_data参数指定悬停时显示的字段,series和reg_date_raw是原始字符串,能帮助答辩时解释数据来源。quantile(0.95)截断顶部 5% 离群点,使趋势线更反映主流规律——毕设图表不是追求统计严谨,而是呈现业务可读的洞察。
4.3 城市均价热力图:用 Plotly 的 choropleth_mapbox 替代百度地图 API
百度地图 API 需申请密钥、有调用量限制,且毕业设计部署到本地电脑时无法联网调用。我们改用 Plotly 的choropleth_mapbox,加载中国省级 GeoJSON(已内置),用城市名称匹配properties.name,完全离线。
# 数据准备:先按城市聚合均价 city_avg = clean_df.groupby("city")["price"].agg(["mean", "count"]).round(0) city_avg.columns = ["avg_price", "count"] city_avg = city_avg.reset_index() # 加载中国省级 GeoJSON(Plotly 内置,无需下载) import plotly.graph_objects as go import json # 中国省级 GeoJSON(精简版,仅含省份名和几何) with open("china-provinces.json", "r", encoding="utf-8") as f: geojson = json.load(f) fig = go.Figure(go.Choroplethmapbox( geojson=geojson, locations=city_avg["city"], z=city_avg["avg_price"], colorscale="Viridis", zmin=city_avg["avg_price"].quantile(0.1), zmax=city_avg["avg_price"].quantile(0.9), marker_opacity=0.5, marker_line_width=0.5, text=city_avg["city"] + "<br>均价:" + city_avg["avg_price"].astype(str) + "元<br>数量:" + city_avg["count"].astype(str), hovertemplate="%{text}<extra></extra>" )) fig.update_layout( mapbox_style="carto-positron", mapbox_zoom=3, mapbox_center={"lat": 35, "lon": 105}, margin={"r":0,"t":0,"l":0,"b":0}, title="各城市二手车均价热力图(单位:元)" ) fig.write_html("city_heatmap.html")注意:
china-provinces.json是标准 GeoJSON 文件,包含"features": [{"properties": {"name": "广东省"}, "geometry": {...}}, ...]。可从公开地理数据站下载,文件约 1.2MB,无需额外依赖。
5. 避坑:毕业设计答辩前必须验证的 5 个血泪现场
5.1 现象:爬虫跑了一夜,CSV 里全是乱码“涓€娆℃€у杞﹂」”
原因:网页编码未显式声明(<meta charset>缺失),requests 默认用 ISO-8859-1 解码中文,导致content.decode("ISO-8859-1")错误。
解决:强制指定编码。在requests.get()后添加r.encoding = r.apparent_encoding,或直接用r.content.decode("utf-8")(若确定网页为 UTF-8)。毕设最稳方案:统一用r.content+BeautifulSoup(..., from_encoding="utf-8")。
5.2 现象:清洗后price列全是NaN,但原始 CSV 明明有数字
原因:正则表达式未覆盖平台新出现的写法。例如新增“¥58,000”带逗号,原正则r"[\d.]+"会匹配58,(逗号后截断)。
解决:清洗函数增加容错分支。在clean_price中补充:
# 新增:处理带逗号的价格 if "," in x: x = x.replace(",", "")玄学经验:在答辩前一周,手动抽样 50 条最新页面源码,粘贴到正则测试网站(regex101.com)验证表达式。
5.3 现象:Plotly 图表在 HTML 中显示空白,控制台报错Uncaught ReferenceError: Plotly is not defined
原因:fig.write_html()生成的 HTML 默认内联 Plotly JS(体积大),但某些杀毒软件或校园网会拦截外部资源加载,导致 JS 加载失败。
解决:强制使用 CDN 并指定完整 URL:
fig.write_html( "output.html", include_plotlyjs="https://cdn.plot.ly/plotly-2.24.1.min.js", # 指定CDN链接 full_html=True )提示:CDN 链接中的版本号
2.24.1需与本地plotly.__version__一致,避免兼容问题。
5.4 现象:pd.to_datetime()解析“2018年”时,全部变成2018-01-01,导致车龄计算偏差
原因:to_datetime对不完整日期默认补全为当月首日,但二手车“2018年”通常指“2018年内任意时间”,按2018-06-01(年中)更合理。
解决:自定义解析函数,对只有年份的字符串补"-06-01":
def safe_to_datetime(x): if pd.isna(x): return pd.NaT x = str(x).strip() if re.match(r"^\d{4}$", x): # 纯4位数字 return pd.to_datetime(f"{x}-06-01") elif re.match(r"^\d{4}年$", x): return pd.to_datetime(f"{x[:4]}-06-01") else: return pd.to_datetime(x, errors="coerce")5.5 现象:答辩时演示“高性价比车型雷达图”,老师问“性价比怎么定义的?”,答不上来
原因:雷达图用了price/age作为指标,但未在报告中明确定义。性价比应是“单位车龄的价格”,值越小越好,但图中未标注方向。
解决:在雷达图代码中强制反转轴(range设为递减),并添加注释:
# 性价比 = 价格 / 车龄(元/年),值越小越好 → 雷达图半径越小代表性价比越高 fig = px.line_polar( data_frame=top_models, r="value", # value = price/age theta="model", line_close=True, range_r=[top_models["value"].max(), 0], # 反转:最大值在内圈 ) fig.update_layout(title="高性价比车型雷达图(值越小越优)")血泪经验:所有图表标题末尾必须加括号注明解读逻辑,这是答辩老师扫一眼就能抓住重点的关键。
6. 毕业设计交付包:3 个文件搞定答辩与归档
6.1 最小交付清单(答辩老师只看这 3 个文件)
毕业设计的核心价值不是代码量,而是能否用一套流程解决真实问题。我们压缩为 3 个不可删减的交付物:
| 文件名 | 格式 | 作用 | 答辩时如何用 |
|---|---|---|---|
report.html | HTML | 交互式可视化报告,含全部 6 张图 + 数据摘要 | 直接双击打开,用浏览器演示悬停、缩放、筛选 |
analysis.pdf | 用pdfkit从 HTML 导出的静态版,含封面、目录、图表截图、文字分析 | 打印交给老师,作为书面材料归档 | |
main.py | Python | 全流程脚本:爬取 → 清洗 → 可视化 → 生成 report.html | 在答辩电脑上python main.py演示端到端运行(需提前装好依赖) |
# 一键生成 PDF(需安装 wkhtmltopdf) pip install pdfkit # 在代码中调用 import pdfkit pdfkit.from_file("report.html", "analysis.pdf", options={"enable-local-file-access": ""})注意:
enable-local-file-access是关键参数,否则 PDF 中图片路径失效。
6.2main.py的黄金结构:让老师 30 秒看懂你的工作流
老师不会逐行读代码,但会扫视main.py结构判断工作量。我们按“采集→清洗→分析→输出”四段式组织,每段用# === [阶段名] ===分隔,并在开头加一行注释说明该段耗时与数据量。
# main.py —— 毕设主入口(答辩时展示此文件) # === 数据采集:耗时约12分钟,获取5237条原始记录 === from crawler import crawl_all_pages raw_df = crawl_all_pages(start_page=1, end_page=50) # 抓50页 raw_df.to_csv("raw_cars.csv", index=False, encoding="utf-8-sig") # === 数据清洗:耗时约8秒,生成4821条有效记录 === from clean import clean_data clean_df = clean_data(raw_df) clean_df.to_csv("clean_cars.csv", index=False, encoding="utf-8-sig") # === 可视化分析:耗时约3秒,生成6张交互图表 === from viz import generate_all_charts generate_all_charts(clean_df) # 输出 report.html # === 交付物打包:生成PDF与README === import pdfkit pdfkit.from_file("report.html", "analysis.pdf", options={"enable-local-file-access": ""})我的习惯:在答辩前 3 天,用一台全新安装 Python 的电脑(如室友笔记本),只装requirements.txt中的库,然后运行python main.py。如果全程无报错、report.html正常打开、analysis.pdf图片完整——说明交付包真正可靠。这比反复检查代码有用十倍。
希望帮到你。
本文还有配套的精品资源,点击获取