☰
二手房数据可视化系统:Python爬虫+Flask+ECharts实战解析
2026/10/11 12:39:46 网站建设 项目流程

简介:基于Python的二手房数据可视化系统设计资料包,面向计算机、数据科学及信息管理相关专业学生,适用于毕业设计、期末大作业或课程设计。项目完整覆盖二手房交易数据的爬取、清洗、分析与可视化流程,利用数据处理和科学计算库完成预处理,再通过可视化库生成柱状图、折线图、散点图等图表,直观呈现房价分布与变化趋势;爬虫程序可抓取房源价格、面积、位置、建成年代等信息。资源共113个文件,大小约29.29MB,包含18个Python脚本、17个CSV数据文件、15个HTML页面、16个JavaScript脚本及17张图表图片,前后端代码齐备,目录结构清晰,便于二次开发与学习。随包附带系统界面截图、操作演示视频及结果分享PPT,可供答辩展示或项目说明使用。已有47人学习下载,适合希望快速搭建同类可视化系统并理解完整项目实现思路的开发者参考。

1. 二手房数据可视化系统:毕设模板里最容易被“数据”卡住的环节

做一个二手房数据可视化系统,大部分人的第一个念头是“画图”,真正动手才发现,最耗时间的不是 ECharts 配置,而是“数据拿到手是脏的、格式是乱的、字段对不上”。这套基于 Python 的系统设计资源,正好把整条链路——爬虫采集、pandas 清洗、Flask + ECharts 展示——串成了一条能直接跑起来的主线。我拆完这套项目的感受是:它不是那种花哨的“大屏可视化”,而是课程设计和毕业设计里最容易过审、也最容易二次改造成自己城市数据的路线。适合两类人:一是做数据可视化课程大作业、毕设的学生,需要一套能讲清楚“数据从哪来、怎么处理、怎么展示”的完整流程;二是想自己搭一个房价分析小工具、但不想从零写页面的从业者。先把结论放这里:这个系统的价值不在图表数量,而在“换数据源也能跑”的工程结构。

2. 数据采集模块:爬虫怎么写、翻页怎么翻、请求被限怎么处理

2.1 数据源选型:为什么第一选择是链家结构

二手房数据可视化最麻烦的不是可视化,而是数据。常见的数据源有链家、贝壳、安居客、58 同城。我在实际拆解时发现,链家的二手房列表页结构最适合教学场景:列表页、详情页、区域筛选是分离的,字段也比较规整,比如总价、单价、面积、户型、楼层、朝向、装修、小区名,基本都集中在同一个 HTML 块里;而且链家的页面按城市和区域做 URL 前缀,换城市只需要换前缀。

相比之下,贝壳的数据不少是接口异步加载的,初学者一抓返回空页面容易劝退;安居客和 58 的字段不够规整,很多时候“单价”要自己从杂项文本里抠。所以这个系统设计里把数据源定位在链家或贝壳这类结构化较清晰的站点上,属于常见做法。要注意的是,这类站点都有反爬策略,核心是限制请求频率、校验 headers。正常浏览速度下,每秒 1 到 2 个页面是相对安全的节奏。

2.2 列表页爬虫的主体代码

爬虫部分通常由三块组成:请求页面、解析字段、保存结果。我用一个最小可运行版本来说明,实际系统里可以再加断点续爬和重试。

# -*- coding: utf-8 -*- import requests import time import csv import re from fake_useragent import UserAgent headers = {"User-Agent": UserAgent().random} def get_one_page(city, page): # city: 城市拼音前缀,比如 beijing # page: 第几页,链家每页 30 条 url = f"https://{city}.lianjia.com/ershoufang/pg{page}/" try: resp = requests.get(url, headers=headers, timeout=10) resp.encoding = "utf-8" if resp.status_code != 200: return None # 页面没拿到就直接返回空 return resp.text except requests.RequestException as e: print("请求失败:", e) return None def parse_page(html): # 这里用正则在 HTML 中抽取房源卡片 # 每套房子在 li[class=clear] 里,字段格式形如 # <div class="totalPrice"><span>438</span>万</div> # <div class="unitPrice"><span>54321</span>元/平</div> result = [] # 常见做法是按字段分条提取,再用 zip 对位组装 # total_price_text = re.findall(r'<div class="totalPrice">.*?<span>(.*?)</span>', html) return result

抓页面的时候有几个参数值得解释一下:

  • timeout=10:限制单次请求最长等待时间,避免某个页面卡死让整个爬虫挂起。10 秒在正常网络下足够,如果网络差可以调到 15。
  • resp.encoding = "utf-8":强制指定编码。链家页面是 UTF-8,但如果请求时没带对编码头,requests 会根据响应头猜测,偶尔会猜成 GBK,导致中文乱码。
  • headers里的 User-Agent 建议随机化,fake_useragent库每次生成一个不同的浏览器 UA,比一直用一个默认 UA 更不容易被识别为脚本。

拿到 HTML 之后解析。这里给大家一个更稳的思路:先打印一段 HTML,确认字段对应的 class 名再写正则,不要靠记忆写格式。很多毕业设计翻车就是“以为字段是class=totalPrice,结果页面结构改版了”,正则全空,后面清洗就跟着白做。

2.3 翻页、限速与断点续爬

链家二手房列表页超过 100 页后会限制访问,所以一般爬前 20 到 50 页就够分析用了。主循环可以写成这样:

def main(): city = "beijing" all_data = [] for page in range(1, 51): # 前 50 页 html = get_one_page(city, page) if html: page_data = parse_page(html) all_data.extend(page_data) print(f"第 {page} 页抓到 {len(page_data)} 条") time.sleep(1.5) # 每页间隔 1.5 秒 with open("house_data.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["小区", "区域", "总价", "单价", "面积", "户型"]) writer.writeheader() writer.writerows(all_data) if __name__ == "__main__": main()

time.sleep(1.5)是这一整段里最重要的参数。如果你把间隔改成 0,多半在第 20 页左右开始遇到访问被限制。这个限制是暂时性的,停一停就能恢复,但爬虫进程不会自动等,所以遇到连续几次返回空页面时,常见做法是“退避式等待”:第一次失败等 5 秒,第二次等 10 秒,最多等 60 秒,然后再重试,而不是无限重连。

断点续爬的常见做法是记录page下标,比如写一个progress.txt,每次成功抓完一页就更新页码。下次启动时先读这个文件,从上次的位置继续,避免因为被限制导致前功尽弃。这个环节在数据可视化系统设计里往往被忽略,但答辩时提到“支持断点续爬”,会显得整体工程更完整。

3. 数据清洗与标准化:把“万”“元/平”“暂无数据”变成能聚合的数值

3.1 抓下来的原始数据长什么样

爬虫保存下来的 CSV 通常是这样的:

字段示例原始值
小区阳光花园
区域朝阳 / 望京
总价438万
单价54321元/平
面积82.5平米
户型2室1厅
朝向南
装修精装

这里有两个直接问题:总价和单价是字符串,带着“万”“元/平”,没法直接求均值;面积里的“平米”不统一,有的记录叫“㎡”;更麻烦的是有些字段是“暂无数据”或者空字符串。如果不处理,做出来的图表会非常难看:价格轴上出现字符串报错,或者某个区域因为缺数据直接没有柱子。

3.2 pandas 清洗主流程

清洗阶段的核心就是两步:把带单位的字符串转成数值,把缺失和异常值处理掉。下面这段代码是可以直接放到项目里跑的:

import pandas as pd df = pd.read_csv("house_data.csv", encoding="utf-8-sig") # 1. 总价去单位:'438万' -> 438.0 df["总价"] = df["总价"].astype(str).str.replace("万", "", regex=False) df["总价"] = pd.to_numeric(df["总价"], errors="coerce") # 2. 单价去单位:'54321元/平' -> 54321.0 df["单价"] = df["单价"].astype(str).str.replace("元/平", "", regex=False) df["单价"] = pd.to_numeric(df["单价"], errors="coerce") # 3. 面积去单位:'82.5平米' / '82.5㎡' 统一 df["面积"] = df["面积"].astype(str).str.replace(r"(平米|㎡)", "", regex=True) df["面积"] = pd.to_numeric(df["面积"], errors="coerce")

一段段拆开看:

  • astype(str)保证每个值都是字符串,避免某个单元格已经是数字时报replace不支持的问题。这是新手最容易踩的坑,CSV 读进来某一列可能混合字符串和数字。
  • str.replace("万", "", regex=False)里regex=False的意思是按字面替换,不是正则。对于“万”这种固定文字,没必要开正则,开正则反而容易把别的字段里的“万”误删。
  • pd.to_numeric(..., errors="coerce")遇到转不了的值(比如“暂无数据”)会变成NaN,而不是抛异常。这就是清洗时最关键的“后悔药”,它把脏数据降级为缺失值,后面统一处理。

对“暂无数据”的处理我一般用两步:先看占比,占比小直接删除整行;占比大就分区域均值填充。具体代码如下:

print(df[["总价", "单价", "面积"]].isna().sum()) # 删除关键字段为空的记录 df = df.dropna(subset=["总价", "单价", "面积"]) # 删除重复房源(同一小区+户型+面积+总价视为重复) df = df.drop_duplicates(subset=["小区", "户型", "面积", "总价"])

这里dropna的subset参数很关键:只检查这三列,如果检查全表,会把“装修”为空但价格正常的房源也删掉,白白损失数据。重复删除同理,用业务上认为“同一条房源”的字段组合来判定,不要整行去重,因为两个不同房源很可能朝向、楼层不同但其他字段相同。

3.3 价格分箱与区域聚合

可视化里比较常见的两个分析维度:一个是“各区域均价”,一个是“价格区间分布”。区域聚合直接groupby就行:

avg_price = df.groupby("区域")["单价"].agg(["mean", "median", "count"]) avg_price.columns = ["均价", "中位数", "挂牌量"] avg_price = avg_price.sort_values("均价", ascending=False) avg_price.to_csv("区域均价.csv", encoding="utf-8-sig")

价格区间分布用pd.cut分箱:

bins = [0, 30000, 50000, 80000, 100000, 200000] labels = ["3万以下", "3-5万", "5-8万", "8-10万", "10万以上"] df["价格段"] = pd.cut(df["单价"], bins=bins, labels=labels) price_dist = df.groupby("价格段", observed=False).size()

pd.cut的边界要以你所在城市的价格分布来定。北京、上海区间要上探到 10 万以上,成都要放到 2 万起步。这种参数放到可视化系统设计里,就是后期最容易被老师追问的“为什么这么分箱”的点,回答“按城市价位动态配置”会比“写死的”听起来更合理。

清洗和聚合完成之后,数据就变成了几个很小的 CSV 或 JSON,这些就是可视化的输入。到这一步,系统才真正进入“数据可视化”的环节。

4. 可视化落地:Flask 后端 + ECharts 前端的页面工程

4.1 为什么选 Flask + ECharts 这套组合

这个系统设计里可视化部分最常见的技术选型是两类:一类是 Pyecharts,在 Python 里直接生成图表再渲染成 HTML;另一类就是 Flask 提供数据接口,前端用 ECharts 原生画图。我在实际拆解中更推荐后者,原因是:

  • 答辩和演示时,页面是可以交互的,鼠标悬停出数值、点击图例筛系列,这套交互是原生 ECharts 最顺手。
  • Pyecharts 生成的 HTML 虽然开箱即用,但想改交互细节(比如联动、钻取、地图下钻)时,被封装层限制得比较多,得绕进生成后的 JS 里改,反而更麻烦。
  • Flask 接口将来换成其他前端(比如 Vue 里的开发页面),后端不用动,数据可视化系统的“数据层与展示层分离”也更经得起提问。

4.2 Flask 数据接口

后端只需要三个接口:首页、区域均价接口、价格分布接口。代码量不大,但结构要清晰:

from flask import Flask, jsonify, render_template import pandas as pd app = Flask(__name__) df = pd.read_csv("区域均价.csv", encoding="utf-8-sig") @app.route("/") def index(): return render_template("index.html") @app.route("/api/avg_price") def api_avg_price(): data = df.sort_values("均价", ascending=True) # 排序放在后端,前端少干活 return jsonify({ "areas": data["区域"].tolist(), "prices": data["均价"].tolist(), "counts": data["挂牌量"].tolist() }) @app.route("/api/price_dist") def api_price_dist(): dist = pd.read_csv("价格分布.csv", encoding="utf-8-sig") return jsonify({ "segments": dist["价格段"].tolist(), "count": dist["count"].tolist() }) if __name__ == "__main__": app.run(host="127.0.0.1", port=5000, debug=False)

强调几个细节:

  • host="127.0.0.1"是只允许本机访问,演示时安全。如果你要给同学用手机看,改成0.0.0.0,这样同一局域网内的设备能通过你的电脑 IP 访问页面。
  • debug=False,课程设计跑演示时不要开 debug 模式。debug 模式会在代码修改时自动重启,还会在页面报错时输出调用栈,在答辩现场出现这些是减分的。本地调试可以用debug=True,演示前必须关掉。
  • 接口返回 JSON 时,把df的排序、去重都放在后端做,前端只负责渲染,这样前端代码能保持很薄,也不会出现“图表 Axes 顺序乱跳”的玄学问题。

4.3 ECharts 显示区域均价的页面

前端index.html的核心部分如下:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>二手房数据可视化</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="avgPriceChart" style="width: 90%;height:480px;"></div> <script> fetch("/api/avg_price") .then(res => res.json()) .then(data => { var chart = echarts.init(document.getElementById("avgPriceChart")); chart.setOption({ title: { text: "各区域二手房挂牌均价" }, tooltip: { trigger: "axis" }, xAxis: { type: "category", data: data.areas }, yAxis: { type: "value", name: "元/平米" }, series: [{ type: "bar", data: data.prices, itemStyle: { color: "#5470c6" } }] }); }); </script> </body> </html>

这套写法有几个值得注意的控制点:

  • echarts.init传入的div必须有显式宽度高度,否则图表经常渲染成零高白屏。
  • tooltip: { trigger: "axis" }让鼠标悬停显示当前柱子的数值,这个是答辩演示时最常用的交互,老师一般会拿鼠标划一下看数字,所以 tooltip 不能省。
  • ECharts 的 CDN 如果打不开(校园网络、内网环境),图表会报echarts is not defined。常见做法是把echarts.min.js下载到static/js/目录用本地引用,这个我会在第 5 章避坑里单独讲。

4.4 加一个价格分布饼图和联动筛选

在同一个页面里再加一个饼图,展示“价格段分布”,两块图共用同一批数据接口,互不干扰:

fetch("/api/price_dist") .then(res => res.json()) .then(data => { var pie = echarts.init(document.getElementById("priceDistChart")); pie.setOption({ title: { text: "挂牌房源价格段分布" }, series: [{ type: "pie", radius: ["35%", "70%"], data: data.segments.map((name, i) => ({ name: name, value: data.count[i] })) }] }); });

接口层面如果要做“按城市筛选”,只需要在/api/avg_price后面再加一个查询参数:

@app.route("/api/avg_price") def api_avg_price(): city = request.args.get("city", "全部") if city != "全部": df_filtered = df[df["城市"] == city] ...

这里把“城市”挂到 CSV 的列上,接口传参数时带上,前端就能通过下拉框实现城市切换。这种“接口 + 前端筛选”的写法,比在 Python 里起多个 Flask 服务去分别给图要干净得多,这也是这个系统设计里很容易扩展的点:想加“户型占比”“朝向占比”“地铁距离分析”,都只需要加接口和图表配置,不动主流程。

5. 避坑与排查:运行这个系统时的五个翻车现场

这一章我从拆包、跑通到完整演示,把最容易翻车的五个现场按“现象 → 原因 → 解决”记录下来,希望对照着复现的你有实际作用。

5.1 爬虫跑十几页就抓不到数据了

  • 现象:前面几页正常,大概第 15 到 25 页开始页面返回空白或验证码,status_code接近 200 但parse_page抽不到任何字段。
  • 原因:请求频率太高,短时间内大量请求同一个列表结构,触发了站点的访问频率限制。不是 UA 的问题,单纯是被限流。
  • 解决:把time.sleep(1.5)调到 2 到 3 秒;同时加一个失败退避:连续 3 次解析为空就停止,等 60 秒再继续。我一般会写一个简单的计数封锁逻辑,比如fail_count > 3就休眠两分钟。

5.2 CSV 里的中文乱码

  • 现象:用 Excel 打开house_data.csv,中文小区名和区域名全是乱码,但用记事本打开却是正常的。
  • 原因:写 CSV 时用了encoding="utf-8",Excel 默认按 GBK 解析,把 UTF-8 文件读成了乱码。
  • 解决:写入时用encoding="utf-8-sig",带 BOM 的 UTF-8,Excel 能正确识别。后端读取时也要对应,pandas 读utf-8-sig保持一致,不要在清洗时混用utf-8和utf-8-sig,否则会出现“读出来带乱码开头”的隐性 bug。

5.3 Flask 启动提示端口被占用

  • 现象:运行app.run()报OSError: [Errno 98] Address already in use或socket.error: [Errno 56]。
  • 原因:之前跑过一次 Flask 没关掉,进程还占着 5000 端口;或者系统里有其他服务占用了。
  • 解决:换端口,把port=5000改成5001;或者找到旧进程杀掉。Windows 下用netstat -ano | findstr 5000查 PID 再taskkill /F /PID;macOS/Linux 用lsof -i:5000查 PID 再kill -9。演示前建议先pkill -f flask清一遍残留进程,免得现场启动报错。

5.4 ECharts 图表空白,控制台报echarts is not defined

  • 现象:页面打开是空的,F12 看控制台提示echarts is not defined。
  • 原因:CDN 外链加载被网络环境阻断,最常见的是校内网和演示现场没有外网访问权限。
  • 解决:提前把echarts.min.js下载到项目的static/js/目录,标签改成<script src="{{ url_for('static', filename='js/echarts.min.js') }}"></script>。本地文件还会带来一个额外好处,加载速度比 CDN 快,答辩演示时不会出现图表闪一下才出来。

5.5 清洗后条形图的“均价”出现异常大值

  • 现象:正常城市均价 5 万左右,结果某个区域柱子的均价冲到 20 万,或者有负值。
  • 原因:源数据里有异常值,比如“单价”字段为0的条目,或看成了“车位”的房源,或者清洗时把“总面积”混成了单平米价。
  • 解决:在groupby前加一层筛选:
df = df[df["单价"].between(1000, 200000)] # 按城市量级调整上下限

再用结果自查,把df.groupby("区域")["单价"].agg(["mean","median","count"])打印出来,中位数明显偏离均值时优先看是不是异常值造成的。这个坑在数据可视化系统设计中很典型,因为图表太容易让人忽略数据本身的合理性,答辩时老师看一眼就能发现问题。

6. 换数据源也通用的技巧:把整套系统改造成自己的城市或平台

这套系统的真正价值,不是说换一个城市就推倒重来,而是把“爬取、清洗、可视化”三个环节解耦,让每一层都能单独替换。我做这套改造时最常用的是一个 config 文件把所有可变参数收进去:

CONFIG = { "city_prefix": "chengdu", # 链家城市前缀 "max_page": 30, # 爬取页数 "price_bins": [0, 15000, 25000, 40000, 60000, 100000], "price_labels": ["1.5万以下", "1.5-2.5万", "2.5-4万", "4-6万", "6-10万"], "exclude_keywords": ["车位", "地下室"], "encoding": "utf-8-sig" }

这样改造城市逻辑时,只需改city_prefix和分箱参数,爬虫、清洗、可视化代码一行都不用动。换成贝壳等其他平台时,也只需要把parse_page里的字段抽取规则换掉,后面清洗和展示部分继续复用,这是这个项目结构最大的性价比所在。

还有一个很实用的习惯:把清洗后的结果直接存 SQLite,而不是每次重新爬。df.to_sql("house", conn, if_exists="replace")之后,再跑可视化时直接从数据库读,能省掉一大半时间。我当时因为没有做这一层缓存,同一个数据集反复爬了三遍,才意识到“爬一次、洗一次、存起来、随便画”才是正确顺序。

最后一个答辩高频技巧是把 ECharts 图表导出成静态 HTML。EChartssetOption之后调用chart.getDataURL()拿到图片 base64,或者直接保存为 HTML 文件,方便在没法启动 Python 服务的机器上演示。我习惯的做法是写一个小的导出脚本:清洗完数据后自动生成一份“报告页”,包含价格分布、户型占比和区域排名,这样即使现场断网、后端起不来,也能用浏览器打开静态页把结果讲清楚。有一次我在演示前 20 分钟 Flask 崩了,靠的就是这份静态报告撑过了现场,从那以后每次做这类数据可视化系统设计,我都会强制多走一步——导出静态版,再启动服务。这个方法虽然小,但关键时刻能当后悔药用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询