简介:这是一份基于Python的二手房数据采集与可视化分析毕业设计项目资源,包含完整源码、配套论文资料与答辩展示,面向计算机、通信、人工智能、自动化等相关专业的学生、教师或从业者,也可作为课程设计、期末大作业或毕业设计的参考蓝本。资源包共157个文件,压缩后约40MB,以Python脚本、CSV数据集、HTML页面和JavaScript交互图表为主,另含PNG截图、配置文件、字体以及答辩PPT等,完整覆盖数据抓取、清洗、存储与可视化展示链路。目前已有179人学习浏览,项目代码均经过调试测试,答辩评审分达到98分,运行稳定性好、可复现性强。内置多份不同编码、不同清洗阶段的二手房数据快照,便于对照理解每一步处理逻辑;论文与PPT可辅助梳理论文结构,目录层次清晰。整体项目围绕爬虫采集、数据清洗、存储入库和可视化展示四大模块组织,各阶段文件独立可查,支持在此基础上替换数据源、扩展分析维度,快速搭建自己的二手房数据分析系统。
1. 二手房数据采集及可视化:毕设选题里性价比最高的一条路
答辩现场最怕听到的三个问题:数据从哪来、怎么保证数据没问题、分析图表跟你的代码是不是对得上。很多同学代码能跑,但数据量少得可怜,图表是从别处截的,一问就露馅。二手房数据采集及可视化这个题目的价值在于,它天然自带闭环:采集能拿到真实数据,清洗能做数据治理,可视化能讲出价格、区域、户型、面积之间的关系,论文里每一个图表都能指到对应代码和数据行,答辩时底气完全不一样。
这篇文章写给两类人:一类是正在选毕设题、想找一个能稳定落地又不太依赖硬件的Python项目;另一类是已经定了题但被反爬、清洗、图表中文乱码反复折腾的人。我会按从业者做这类项目的通用路径来拆:采集选型、字段设计、清洗入库、可视化分析,以及我踩过的那几个坑。全程用可复现的代码片段,参数我都写清楚为什么这么设,你照着改就能用。
2. 采集侧选型与结构化解析:requests + BeautifulSoup 的最小闭环
2.1 为什么先选 requests 而不是一上来就上 Scrapy
做二手房采集,第一反应往往是Scrapy,因为它是爬虫框架、有并发、有中间件,听起来更专业。但以毕业设计为交付目标,我建议先冷静一下。Scrapy的调试成本高,Item Pipeline、Spider中间件、Twisted异步这些概念,你论文里要解释清楚就得写好几页,而且出问题时很难快速定位。相反,requests + BeautifulSoup是同步请求、单页解析,代码顺序跟人脑思考顺序一致,逻辑出错一眼就能看出来。
我在做这类采集时反而刻意不用Scrapy,主要原因是落地的稳定性。毕设不是做大规模爬虫,数据量几千到几万条就足够支撑分析,同步请求完全够用。requests负责发HTTP请求拿HTML,BeautifulSoup负责从HTML里抽取挂牌信息,两个库加起来依赖少、调试直观。更重要的是,论文里描述这套方案的思路非常清晰:发起请求、解析页面、提取字段、翻页循环。评审老师看到的是你理解了这个过程,而不是只会调框架。
下面是一个最小闭环的采集骨架,我一般会先拿单个列表页跑通,再套翻页循环。这个脚本针对的是某头部经纪平台的挂牌列表页,你实际使用时需要根据目标页面的结构调整选择器,但它把关键环节都带出来了:
import time import random import requests from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_listing_page(city_code, page_num): # city_code 控制城市,page_num 控制翻页,具体参数名要以目标网站实际接口为准 url = f"https://example.com/{city_code}/ershoufang/pg{page_num}/" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding # 避免中文乱码 if resp.status_code != 200: return None return resp.text def parse_listing(html): soup = BeautifulSoup(html, "html.parser") items = [] # 每个挂牌卡片通常在一个重复的 li 或 div 结构里,需按页面实际情况换选择器 for card in soup.select("li.listing-item"): title = card.select_one("div.title a") total_price = card.select_one("div.total-price span") unit_price = card.select_one("div.unit-price") if title and total_price and unit_price: items.append({ "title": title.get_text(strip=True), "total_price": total_price.get_text(strip=True), "unit_price": unit_price.get_text(strip=True), }) return items for page in range(1, 4): html = fetch_listing_page("sh", page) if html: data = parse_listing(html) print(f"page {page}: got {len(data)} items") time.sleep(random.uniform(1.5, 3.0))这段代码的逻辑分三层:请求层负责拿HTML,解析层负责抓标题和价格字段,调度层控制翻页和延时。三个参数值得你重点关注。timeout=10是请求超时上限,防止某个页面挂死导致程序卡住不往后走;apparent_encoding是根据页面内容自动推断编码,二手房平台很多页面是GBK或GB2312,直接默认UTF-8解码会出现中文变问号;random.uniform(1.5, 3.0)是每次翻页之间的随机延时,作用是让请求间隔不均匀,避免固定间隔被识别出自动化特征。
有同学会问,为什么不像爬虫教程里那样用json.loads去解析接口数据。因为二手房挂牌页大多数情况下服务端渲染的是完整HTML,接口形式不统一,有的需要签名参数,有的在XHR请求里加密了payload。直接解析HTML反而最稳定,你只需要关心页面结构,而不需要逆向JS逻辑,这是毕设周期里最明智的取舍。
2.2 挂牌详情解析:字段设计、翻页循环与参数
采集字段的设计决定了后期分析能做多深。很多同学只抓了标题、总价、单价就收工,结果做可视化时发现面积、朝向、楼层都没有,什么对比都做不了。我一般会在解析阶段直接抓全字段,宁可多存几列,也不要后期返回去补采。
以下是二手房挂牌详情里比较通用的字段表,你抓取时对照着这个清单去页面里找对应元素:
| 字段 | 数据类型 | 含义 | 采集注意点 |
|---|---|---|---|
| 小区名称 | 字符串 | 房源所在小区 | 不同平台写法差异大,需要规范化 |
| 区域 | 字符串 | 所在行政区及商圈 | 通常是“区-商圈”两级结构 |
| 总价 | 字符串原始值 | 挂牌总价 | 注意有“万”后缀,需清洗 |
| 单价 | 字符串原始值 | 每平米单价 | 有“元/平”后缀,部分平台隐藏 |
| 建筑面积 | 字符串原始值 | 房屋面积 | 单位是㎡,需转float |
| 朝向 | 字符串 | 东/南/西/北或组合 | 可能缺省,需做缺失处理 |
| 楼层 | 字符串 | 低/中/高楼层 | 有的平台是“/共6层”结构 |
| 装修 | 字符串 | 毛坯/简装/精装 | 部分房源此项为空 |
| 挂牌时间 | 字符串 | 房源发布时间 | 直接影响时效性分析 |
字段抓不全的根源,是页面结构里标题和详情不在同一个标签层级,或者某些字段只存在于详情页而不在列表页。我的做法是列表页抓全部卡片字段,然后只对缺失字段的房源发起详情页请求,按房源ID拼接URL。这一步看似多写了不少代码,但它直接决定了你后面做分析时能不能讲出“不同区域的单价差异”“面积与总价的关系”这种结论。
翻页循环需要注意边界条件。有的网站翻页到最后一页会返回空列表,有的会返回重复的第一页数据,还有的会在URL里把页码参数名改成pageNo而不是pg。我建议在循环里加一个终止判断:如果连续三页解析结果都为空,就主动停止,而不是傻傻地请求到第100页。这个判断还能避免网站改版后无限请求空页面浪费时间。
2.3 温和反爬策略:User-Agent、延时与失败重试
反爬是采集逃不开的话题,但毕业设计不需要高深的对抗手段,你只需要做到“不被封、够用、可解释”。我的策略是温和三件套:自定义User-Agent、随机延时、失败重试。这三件事的成本极低,但能避免大部分初级的封禁。
import requests from requests.adapters import HTTPAdapter session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", }) adapter = HTTPAdapter(max_retries=3) session.mount("http://", adapter) session.mount("https://", adapter) def safe_get(url, session=session): try: resp = session.get(url, timeout=10) resp.raise_for_status() return resp except requests.RequestException as e: print(f"request failed: {url}, retrying later") return None这里用到了HTTPAdapter(max_retries=3),它让每次请求在网络超时或连接被重置时自动重试最多3次,省去手动写重试逻辑。重试之间的退避时间由底层urllib3的指数退避控制,是默认行为。关键是不要在重试代码里再加固定延时,否则请求速率会不均匀,反而更容易触发反爬。
有人会问要不要上代理池。以我的经验,毕设场景完全没必要。代理池的维护成本很高,免费代理大多不可用,付费代理又要额外开销,而且代理本身也可能被封。温和采集、控制单日总量,对模拟项目和论文来说是完全够用的。另一个血泪经验是别在高峰期采集,比如晚上八点到十一点是用户访问高峰,网站的限流策略更敏感;我一般安排在深夜或凌晨跑,成功率明显更高。
3. 数据清洗与存储:把网页文本变成可分析的数据集
3.1 字段规范化:从“120万”“98㎡”变成数值
采集下来的数据是给人看的文本,不是给计算用的数值。直接拿“120万”去做均值计算,会得到NaN。字段规范化是可视化分析前最枯燥但最重要的一步,这一步做不好,后面所有图表都会翻车。
import pandas as pd import re def clean_price(value): # 输入是类似 "120万" 的字符串,输出数值(单位:万) if not isinstance(value, str): return None value = value.replace("万", "").replace("\u00a0", "") try: return float(value) except ValueError: return None def clean_unit_price(value): # 输入类似 "58000元/平" 或 "5.8万/平" ,统一换算成元/㎡ if not isinstance(value, str): return None if "万" in value: number = float(re.search(r"[\d.]+", value).group()) * 10000 else: number = float(re.search(r"[\d.]+", value).group()) return number def clean_area(value): # 输入类似 "89.5㎡" 或 "89.5平" ,输出平方米数值 if not isinstance(value, str): return None match = re.search(r"[\d.]+", value) if match: return float(match.group()) return None这三个清洗函数解决的是挂牌文本里最常见的格式问题。clean_price把“万”字后缀去掉并转浮点,顺便处理了页面里常见的\u00a0这个不换行空格;clean_unit_price要处理“万/平”和“元/平”两种写法,统一换算成元为单位;clean_area只提取字符串中的数字,不关心后面跟的是“㎡”还是“平”。正则里的[\d.]+匹配连续数字和小数点,已经覆盖了整数和小数面积。
参数层面的关键点是:清洗规则要有兜底。你预期用户输入是“120万”,但实际页面里可能混入“价格待定”“暂无报价”“约120万”这类变体。所以每个函数在无法解析时都返回None,而不是抛异常。这个设计直接决定了清洗流程是安稳跑完还是中途崩掉。
3.2 重复数据与缺失值处理
二手房的重复数据有两个来源:同一个平台里同一套房被多个中介重复挂牌,不同平台之间同一房源也会有交叉。去重的核心思路是找主键,而不是简单地对所有字段完全匹配。
df["is_dup"] = df.duplicated(subset=["小区名称", "建筑面积", "总价", "朝向"]) df = df[~df["is_dup"]].copy() print(f"after dedup: {len(df)} rows")我一般把“小区名称+建筑面积+总价+朝向”四字段组合作为去重主键,这个组合已经能基本覆盖同一房源重复挂牌的场景。不建议加“标题”字段,因为中介写的标题文案差异太大,同一个房源在不同中介手里的标题完全不同,会导致漏去重。去重后看缺失率,df.isnull().sum()按列统计缺失,如果某一列缺失超过30%,就要考虑是否在采集阶段补充详情页抓取,而不是在清洗阶段硬填。
缺失值处理有两种常见策略:数值型字段用中位数填充,类别型字段单独标记为“未知”。但这里有个玄学问题:很多同学喜欢用均值填充总价,结果做了个均价图,发现均值被缺失值拉低,整个结论都失真。我的习惯是删除关键字段缺失的行,例如总价、面积、区域这三个字段缺失时直接丢弃,而不是填充。
3.3 存储入库:SQLite 建表与循环利用
清洗后的数据要落地存储。SQLite是毕设项目的最佳选择,它不需要单独安装数据库服务,一个文件就是整个库,论文里可以写“轻量级嵌入式数据库”,听起来也成立。表结构设计直接对应前面清洗后的字段。
CREATE TABLE IF NOT EXISTS house_listing ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, community TEXT, district TEXT, total_price REAL, unit_price REAL, area REAL, orientation TEXT, floor_level TEXT, decoration TEXT, listing_time TEXT, crawl_source TEXT, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP );这里用REAL类型存价格和面积,用TEXT存文本类字段。crawl_source字段记录数据来源,方便表明数据仅用于学习研究。建表后通过df.to_sql("house_listing", conn, if_exists="append", index=False)写入,pandas的to_sql会自动做类型映射,大多数情况下不用手写批量INSERT。
有一个细节值得注意:千万不要把清洗前和清洗后的数据存在同一个表里。我吃过这个亏,第一次用if_exists="replace"直接覆盖了原始表,后面想回头对比清洗差异时完全没有依据。现在我会建两个表:house_listing_raw存采集原始数据,house_listing_clean存清洗后结果。这样论文里能写清洗步骤的统计对比,比如“原始数据共12000条,清洗后有效数据9800条,去重率18%”,这就是答辩时很好的过程性证据。
4. 可视化分析:从价格分布到区域对比的几个必做图表
4.1 分析维度怎么定:先想清楚论文要讲什么结论
可视化的误区是先有图后找结论,导致图表一堆但讲不出完整故事。我习惯先写两到三句结论句,比如“该市二手房均价呈现中心城区向外围递减的圈层结构”,再反推需要哪些图表来支撑这个结论。区域均价对比柱状图、各区域房源数量分布、总价区间占比,这些图表都服务于一个主线叙事。
分析维度至少要覆盖四个方面:价格层面看总价和单价的分布区间;区域层面看不同行政区的均价排名和挂牌量;房源结构看户型、朝向、楼层的占比;相关关系看建筑面积与总价是否线性相关。这四个维度已经能撑起一篇中等篇幅论文的主体分析章节,而且每个维度对应图表时思路清晰。
有一个分析维度经常被忽略:房屋面积与总价的散点关系。这个图表做出来会直接暴露一个现象,即总价不只是随面积线性变化,区域和房龄的影响有时大于面积影响。答辩时拿这张图讲分析深度,效果比堆十个柱状图好得多。数据处理上只需要用清洗好的area和total_price两列,不需要额外特征工程。
4.2 pyecharts 生成区域均价与户型占比的组合图
可视化库我用pyecharts,因为生成的图表是交互式HTML,可以在浏览器里缩放、悬停查看数值,演示时比matplotlib的静态图好看,而且代码量差别不大。需要注意新版pyecharts的链式写法与旧版差异,下面这段代码用新版风格,如果你本地跑的是旧版,需要把add方法的参数位置做调整。
from pyecharts import options as opts from pyecharts.charts import Bar, Pie # 统计区域均价 district_avg = df.groupby("district")["total_price"].mean().sort_values(ascending=False) districts = district_avg.index.tolist() avg_prices = [round(v, 1) for v in district_avg.values] bar = ( Bar() .add_xaxis(districts) .add_yaxis("区域均价(万)", avg_prices) .set_global_opts( title_opts=opts.TitleOpts(title="各区域二手房挂牌均价对比"), yaxis_opts=opts.AxisOpts(name="均价(万)"), ) ) bar.render("district_avg.html") # 户型占比 room_type_counts = df["room_type"].value_counts() pie = ( Pie() .add("", [list(z) for z in zip(room_type_counts.index, room_type_counts.values)]) .set_global_opts( title_opts=opts.TitleOpts(title="二手房户型分布占比"), legend_opts=opts.LegendOpts(orient="vertical", pos_left="left"), ) ) pie.render("room_type_pie.html")两段图表代码的逻辑结构是一样的:先用groupby或value_counts聚合数据,再传入图表组件。sort_values(ascending=False)保证柱状图从左到右按均价从高到低排列,这个排序在答辩演示时很重要,能一眼看出头部区域和尾部区域的差距。饼图里的[list(z) for z in zip(...)]是把pandas的索引和值组装成pyecharts要求的二维列表格式,如果你写room_type_counts.items(),新版pyecharts会直接报类型错误。
中文字体问题在pyecharts里相对少一些,它用浏览器渲染,不依赖系统字体库。但如果你用matplotlib做辅助图,就必须手动指定plt.rcParams["font.sans-serif"] = ["SimHei"],否则坐标轴全是方框。很多同学在这个地方反复踩坑,明明数据没错,图就是不能看。
4.3 让图表服务论点:口径统一与数据快照
可视化阶段的翻车大多数不是代码问题,而是口径问题。均价的计算范围不一致,比如一张图包含所有房源、另一张图只包含满两年房源,柱状图高高低低根本没法对比。我要求自己同一套图表只用一个数据源版本,也就是清洗后的house_listing_clean表,或者直接从一个固定CSV快照读数据。
现在养成的一个习惯是:清洗完数据之后先导出一份clean_data.csv并拷贝一份带时间戳的快照,所有后续图表都从这个快照读取。这样做的好处是图表可复现,论文评审如果提出“你今天的图和论文里的图数据不一致”,你可以指着快照说明所有分析基于哪个数据版本。幸运的是我第一版论文就是这么干的,答辩时直接被问到图表来源,我当场拿快照和脚本复现了图表,这一关过了之后基本没有硬性问题。
还有一个微小的可视化细节是颜色和图例顺序。value_counts()返回的Series默认按数量降序排列,饼图图例顺序和占比顺序一致,但如果你后面用sort_values()重排了数据,图例顺序也要跟着变。这个看似无关紧要的问题,在论文截图里的观感差异非常大,我建议每组图生成后用浏览器打开检查一遍再截图。
5. 毕设避坑:采集、清洗、可视化全流程的5个常见翻车点
5.1 页面结构一变,整个解析器当场报废
现象:采集脚本昨天跑得好好的,今天一运行全返回空列表,打印HTML发现结构完全变了。 原因:目标网站在你采集周期内做了一次前端改版,标签class名称换了,CSS选择器全部失效。 解决:解析层和数据层分离。把选择器集中放在脚本顶部的字典里,SELECTORS = {"card": "div.item", "title": "h3.title"},改版时只改这个字典。另外,采集脚本要加页面变化检测,比如解析结果为空时主动发报警日志,而不是静默写进数据库。
5.2 数据量看着多,清洗完剩不到一半
现象:数据库里有一万多条记录,清洗后只有四千条可用,区域均价分析只剩几个区有数据。 原因:采集阶段没有做字段完整性过滤,大量记录缺失面积、朝向、区域等关键字段,清洗时整体删除。 解决:采集列表页的同时抓详情页,只对缺失字段的房源补采详情。这个代价在毕设里完全可控,详情页数量通常只有列表页的十分之一。清洗时先看每列缺失率,再决定删除还是补采,不要一上来就dropna()。
5.3 价格字段里混入了非数值文本
现象:total_price列清洗后出现大量NaN,均价图缺了好几个区域。 原因:部分房源挂牌价格是“价格待定”或“业主改价”,正则提取数字失败,函数返回None。 解决:清洗函数要分类处理。先判断字符串里是否包含数字,不包含就标记为缺失而不是报错;包含但格式异常的就打印告警,方便回头统一看看是哪些脏格式。我在清洗脚本里专门加了一个bad_format_list,把无法解析的原始值收集起来,答辩时可以直接展示这个清单作为数据质量的实证。
5.4 图表里的中文变成方框或乱码
现象:柱状图的区域名称变成一个个小方块,保存成PNG后问题依旧。 原因:matplotlib库的默认字体是DejaVu Sans,不支持中文。pyecharts理论上没有这个问题的。 解决:matplotlib设置中文字体时要看操作系统环境,plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "WenQuanYi Zen Hei"],把常见中文字体列一个列表按顺序匹配。还要顺带设置axes.unicode_minus=False,否则负号会显示成方块。建议直接用pyecharts输出HTML文件,省去截图后字体失真的问题。
5.5 论文图表和代码口径对不上
现象:论文里的区域均价前三名和仓库代码重新跑出来的结果不一样,答辩被质疑数据造假。 原因:论文写作用的是清洗后某个版本的数据,代码后来又改过清洗规则,两者数据不一致,图表不可复现。 解决:数据快照和代码版本一起锁死。把清洗后的CSV快照放进data/目录,论文里写明“本文所有分析基于2025年4月采集数据,清洗后样本量N=9800条”。答辩前用同一脚本从快照重新生成全部图表,确保图、表、代码三者完全对齐。这个习惯能直接消除掉大多数关于数据真实性的追问。
6. 答辩前的最后一公里:让图表和论文、代码对得上
论文资料整理这件事,我见过太多人拖到答辩前一晚才开始,结果就是图表编号对不上、代码文件乱成一团、README里一句运行说明都没有。这里分享一个我固定使用的项目结构,你直接照着排布就能省去很多麻烦:
project/ ├── data/ │ ├── raw/ │ ├── clean/ │ └── snapshots/ ├── scripts/ │ ├── crawl.py │ ├── clean.py │ └── visualize.py ├── output/ │ └── charts/**/*.html ├── docs/ │ ├── 开题报告.md │ ├── 论文.md │ └── 答辩PPT.md └── README.mddata/snapshots/只放带时间戳的最终数据文件,scripts/三个脚本各自独立可运行,output/charts/按图表用途分目录。README里只需要写三句话:项目做什么、数据怎么获取、代码怎么按顺序运行。这三句话同时也是答辩现场的开场白。
我给这个目录结构配套了一个固定习惯:论文里每引用一个图表,就在图表下方写一行简短的生成说明,例如“数据来源:snapshot_20250401.csv,清洗脚本clean.py,输出图表district_avg.html”。写起来只有一行字,但评审问的时候你可以理直气壮地指出数据和代码路径,而不是含糊地说“这个图之前跑出来的”。
答辩前一周,我会花十五分钟做一次完整回归:从原始CSV快照重新生成所有图表,核对论文里的每个数字和图表数值是否一致。这一轮检查基本能兜住所有低级错误。有一句话我记了很久:毕设项目翻车不可怕,最怕是翻车之后拿不出一个能自洽的版本。数据快照、脚本分离、图表标注,这三个习惯就是你的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取