跟几个准备做毕设的同学聊天,发现大家最近都在纠结同一件事:选题太老、技术栈没亮点、做出来没东西可展示。今天想聊的这个项目,算是这两年比较“聪明”的毕设方向——基于大数据的安客居二手房屋信息采集系统,用Django做后端框架,配合网络爬虫采集二手房数据,再做数据可视化分析。它把爬虫、大数据处理、可视化三个热点技术点全部串起来了,既能体现工作量,又方便在答辩时讲出技术深度。这篇文章我会把这个系统的设计思路、核心模块、数据库设计、关键代码实现、以及我实际调试中踩过的坑全部展开来讲。
这个项目适合谁?如果你是计算机、软件工程、大数据相关专业的学生,想找一个能覆盖全流程、又能快速上手的毕设题目,这个方向很合适。它不需要特别高深的算法基础,但要求你懂Python、会Django、肯花时间调爬虫,做出来之后放到简历上也是个不错的项目经历。下面按照我自己的实现顺序,把这个系统从零到一拆开讲。
1. 项目整体定位与需求拆解
1.1 为什么选这个题目:毕设选题背后的逻辑
我见过太多毕设翻车的案例,最常见的死法不是代码写不出来,而是选题本身就有问题。比如“基于XX的XX管理系统”,这种题目十年前就做烂了,老师看一眼标题就知道你要做什么,没有任何新意。反过来,如果题目太偏算法,比如“基于深度学习的房价预测”,虽然听起来高大上,但本科生做起来很容易变成调包侠,数据找不到、模型跑不动、答辩一问三不知,最后老师反而觉得你是在网上抄的。
这个项目的高明之处在于“数据采集 + 可视化分析”的组合。它不会让你陷入算法深坑,又能展示完整的数据流闭环:爬虫采集原始数据,清洗落库,再通过Django+ECharts展示分析结果。技术点覆盖了网络爬虫、数据存储、后端开发、前端可视化,每一环都是可以展开讲的内容。而且二手房数据是真实存在的,不存在“没有数据可用”的尴尬情况,你把程序跑起来,数据就在那里,这种实感对答辩很有帮助。
1.2 核心需求与技术要点梳理
一个完整的安客居二手房数据可视化分析系统,拆分下来大概包含这几个模块:
- 爬虫模块:负责从目标站点采集二手房信息,包括小区名称、户型、面积、朝向、楼层、总价、单价、所在区域、商圈、建造年份等信息。
- 数据存储模块:设计合理的表结构,将清洗后的结构化数据持久化到MySQL或SQLite中。
- 后端服务模块:基于Django提供数据接口和页面渲染,供前端调用展示统计结果。
- 数据可视化模块:通过图表展示区域均价对比、户型分布、价格区间分布、面积与价格关系、时间走势等。
- 管理后台模块:方便维护数据,支持手动增量抓取或重新抓取。
实际开发过程中,我接触到的很多同学都会忽略“数据清洗”这一步。爬虫抓下来的数据绝对不是干净的,比如面积字段可能带单位、价格可能带“万”字、同一个小区的写法不统一、有的字段直接缺失。如果不做清洗,后面可视化出来的图表全是错的,所以一定要把清洗环节设计成独立模块,而不是在爬虫里顺手处理一下就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型解析:Django、爬虫与可视化的组合逻辑
2.1 为什么用Django而不是Flask
后端框架这块,很多人在Django和Flask之间纠结。我的观点很直接:毕设项目选Django,理由不是Django比Flask“高级”,而是Django的“全家桶”特性在毕设里太加分了。
Django自带Admin后台,你定义完数据模型之后,后台管理页面就自动有了。这意味着你可以少写很多前端页面,把精力放在核心功能上。另外Django的ORM非常好用,你几乎不用写原生SQL,定义好模型类,增删改查全都能搞定。而且Django的模板系统和认证体系都是现成的,做个用户登录、数据管理后台,几乎不需要额外开发。
相比之下,Flask虽然轻量灵活,但很多功能都要自己集成,比如ORM要配SQLAlchemy,Admin要配Flask-Admin,认证要配Flask-Login。对于一个毕设项目来说,这些“额外配置”都是在消耗你的时间。除非你已经有很强的Flask经验,否则没必要给自己找麻烦。
2.2 爬虫方案:Requests + BeautifulSoup 还是 Scrapy
爬虫这块,我推荐视数据量而定。如果只是想跑通流程、抓个几千条数据展示,用Requests + BeautifulSoup就足够了,代码简单直接,调试方便,对新手极其友好。如果目标数据量很大,比如要抓几十个城市的几万条数据,那就应该上Scrapy,它的并发能力、去重机制、错误重试、增量爬取都更完善。
不过说实话,毕设场景下我建议Requests + BeautifulSoup为主,原因有三:一是调试方便,你在Jupyter里就能边写边测;二是逻辑直观,就是一个“发请求-解析HTML-提取数据-入库”的流程,答辩时三言两语就能讲清楚;三是Scrapy框架本身带有一点学习成本,如果你的爬虫经验不够,反而容易在框架上卡住。
如果你希望项目体现更多工程性,可以在Requests的基础上自己封装一个简单的采集器,支持随机User-Agent、请求延时、异常重试,这其实就是在模仿Scrapy的核心思想,讲出来同样有亮点。
2.3 数据可视化:ECharts是首选
数据可视化部分,最省事的选择就是ECharts。它是百度开源的前端可视化库,图表类型极其丰富,柱状图、折线图、饼图、散点图、地图、热力图全都有,而且用完即走,不需要你在前端投入太多时间。
ECharts的引入方式很简单:在HTML里通过CDN加载,或者直接把echarts.min.js文件下载到本地static目录。因为毕设答辩可能需要现场演示,建议下载到本地,避免现场没网导致图表加载不出来。我当时就是这样做的,一个将近1MB的JS文件放在static目录里,所有页面共用,稳定可靠。
ECharts还有一个对毕设特别友好的点:它的官方示例非常多,你可以直接改造官方案例,把数据换成你自己的,不到半小时就能出一个漂亮的图表。后面我细讲每个图表是怎么配置的。
3. 数据库设计与数据清洗实现
3.1 数据库表结构设计
数据结构直接影响后续的可视化分析和页面展示效果,这块值得认真设计。我用MySQL存储数据,表结构设计成一个主表即可,再配一个抓取记录表。
房屋信息主表可以这样建(我精简了字段,实际项目可根据需要增加):
sql复制CREATE TABLE house_info (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) COMMENT '标题',
community VARCHAR(100) COMMENT '小区名称',
area VARCHAR(50) COMMENT '区域',
biz_circle VARCHAR(100) COMMENT '商圈',
house_type VARCHAR(50) COMMENT '户型',
size FLOAT COMMENT '面积(平米)',
orientation VARCHAR(20) COMMENT '朝向',
floor VARCHAR(50) COMMENT '楼层',
year VARCHAR(20) COMMENT '建造年份',
total_price FLOAT COMMENT '总价(万)',
unit_price FLOAT COMMENT '单价(元/平米)',
source_url VARCHAR(500) COMMENT '来源链接',
created_at DATETIME COMMENT '抓取时间',
UNIQUE KEY uk_url (source_url)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手房房屋信息表';
我特别要强调两个字段:source_url要用唯一索引,因为爬虫如果重复运行,同一个房源URL不能被重复插入;created_at一定要记,因为后面做时间趋势分析时全靠它。
unit_price是唯一不需要从爬虫结果中解析的字段吗?其实不是。页面上的“单价”和“总价”往往会同时给出,但有时候单价是“xx元/平米”,总价是“xx万”,你都需要截取数字。我建议把这两个字段都以FLOAT类型存,方便做数学计算,比如算区域均价、价格区间分布。
抓取记录表适合记录每次爬虫任务的执行情况:
sql复制CREATE TABLE crawl_log (
id INT AUTO_INCREMENT PRIMARY KEY,
page_count INT COMMENT '抓取页数',
item_count INT COMMENT '抓取条数',
status VARCHAR(20) COMMENT '状态 success/failed',
message TEXT COMMENT '异常信息',
created_at DATETIME COMMENT '执行时间'
);
这张表看起来不重要,但答辩的时候非常有用。你可以直接展示“今天抓了多少条数据、总共抓了多少条、成功率是多少”,这就是真实的工作量证明,比口头说“我写了一个爬虫”有说服力得多。
3.2 爬虫核心代码实现与解析
爬虫模块我封装成了一个独立的Python脚本,核心思路是:构造列表页URL,获取页面HTML,解析出房源详情页链接,再进入详情页提取完整字段。要注意,安客居这类站点通常有列表页和详情页两种页面结构,列表页里就有大部分关键信息,详情页信息更全但请求量也会翻倍。
我用的是Requests + BeautifulSoup方案,核心代码大概是这样的(以列表页解析为例,我尽量写得贴近实际使用情况):
python复制import requests
from bs4 import BeautifulSoup
import time
import random
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://www.example.com/'
}
def fetch_page(url):
"""请求页面,带异常重试"""
for attempt in range(3):
try:
resp = requests.get(url, headers=HEADERS, timeout=10)
if resp.status_code == 200:
resp.encoding = 'utf-8'
return resp.text
except requests.RequestException:
time.sleep(2)
return None
def parse_list_page(html):
"""解析列表页,提取房源条目"""
soup = BeautifulSoup(html, 'html.parser')
items = []
# 这里的选择器需要根据实际情况调整,用find_all配合class定位
for div in soup.select('.house-item'):
title = div.select_one('.title a')
community = div.select_one('.community')
house_type = div.select_one('.house-type')
size = div.select_one('.house-size')
total_price = div.select_one('.total-price')
unit_price = div.select_one('.unit-price')
# 提取文字并去除空白
item = {
'title': title.get_text(strip=True) if title else '',
'community': community.get_text(strip=True) if community else '',
'house_type': house_type.get_text(strip=True) if house_type else '',
'size': size.get_text(strip=True) if size else '',
'total_price': total_price.get_text(strip=True) if total_price else '',
'unit_price': unit_price.get_text(strip=True) if unit_price else '',
}
items.append(item)
return items
这段代码里我认为最关键的是 strip=True,它可以自动去掉HTML标签内外的空白字符。很多同学解析出来字段带一堆换行和空格,就是因为没加这个参数。
请求频率方面,我实测下来每抓一页sleep 1到2秒比较稳妥,太激进容易触发反爬。可以用 time.sleep(random.uniform(1, 2)) 来做随机延时,比固定延时更有伪装性。另外一定要设置好Headers里的User-Agent,这是最基础的反爬规避手段。
3.3 数据清洗与预处理
抓回来的原始数据,需要经过清洗才能入库。最常见的几个问题:
- 面积字段可能是“89.5平米”或者“89.5㎡”,需要提取数字。
- 总价可能是“182万”,需要去掉“万”字。
- 单价可能是“20000元/平米”,需要提取“20000”。
- 户型可能是“3室2厅1卫”,需要拆成室、厅、卫三个数字。
- 有些字段为空,需要决定是补默认值还是跳过这条记录。
我写了一个清洗函数,把常见的解析逻辑收敛到一个地方:
python复制import re
def clean_size(value):
"""从'89.5平米'中提取89.5"""
match = re.search(r'(\d+\.?\d*)', str(value))
return float(match.group(1)) if match else None
def clean_total_price(value):
"""从'182万'中提取182"""
match = re.search(r'(\d+\.?\d*)', str(value))
return float(match.group(1)) if match else None
def clean_unit_price(value):
"""从'20000元/平米'中提取20000"""
match = re.search(r'(\d+\.?\d*)', str(value))
return float(match.group(1)) if match else None
def clean_house_type(value):
"""从'3室2厅1卫'中拆出(3,2,1)"""
match = re.match(r'(\d+)室(\d+)厅(\d+)卫', str(value))
if match:
return match.groups()
return (None, None, None)
这些函数看起来简单,但它们是整个数据质量的保障。我见过有的同学把“182万”直接转成float,结果程序直接报错,就是因为没有做清洗。
清洗逻辑最好在插入数据库之前执行,不要在展示的时候再处理。否则每次查询都要做正则匹配,效率低,代码也乱。
4. Django后端与数据可视化展示实现
4.1 Django项目结构与接口设计
Django项目我一般按这样的目录结构组织,清晰好维护:
code复制house_project/
├── manage.py
├── house/ # 主应用
│ ├── models.py # 数据模型
│ ├── views.py # 视图函数
│ ├── urls.py # URL路由
│ ├── admin.py # 后台管理
│ ├── migrations/
│ └── files/
│ ├── spider/ # 爬虫脚本
│ └── data/ # 导出数据文件
├── templates/ # 模板文件
├── static/ # 静态资源 js/css
└── config/ # 项目配置文件
models.py里的模型类跟数据库表对应,用Django ORM定义:
python复制from django.db import models
class HouseInfo(models.Model):
title = models.CharField(max_length=255, verbose_name='标题')
community = models.CharField(max_length=100, verbose_name='小区名称')
area = models.CharField(max_length=50, verbose_name='区域')
biz_circle = models.CharField(max_length=100, verbose_name='商圈')
house_type = models.CharField(max_length=50, verbose_name='户型')
size = models.FloatField(verbose_name='面积')
orientation = models.CharField(max_length=20, verbose_name='朝向')
floor = models.CharField(max_length=50, verbose_name='楼层')
year = models.CharField(max_length=20, verbose_name='建造年份')
total_price = models.FloatField(verbose_name='总价')
unit_price = models.FloatField(verbose_name='单价')
source_url = models.URLField(unique=True, verbose_name='来源链接')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='抓取时间')
class Meta:
db_table = 'house_info'
verbose_name = '二手房信息'
verbose_name_plural = verbose_name
视图方面,我建议尽量写JSON接口,结合Ajax渲染图表。比如区域均价接口:
python复制from django.http import JsonResponse
from django.db.models import Avg, Count
from .models import HouseInfo
def area_price_stats(request):
"""各区域房源数量和平均单价"""
data = (HouseInfo.objects
.values('area')
.annotate(avg_price=Avg('unit_price'), count=Count('id'))
.order_by('-count'))
return JsonResponse(list(data), safe=False)
Django的ORM在这里体现出了巨大优势,一个链式查询就把SQL中的 GROUP BY area 和聚合函数表达清楚了,不需要自己去拼字符串。
4.2 可视化页面实现:户型分布、价格走势、区域对比
可视化页面我做了四个主要图表,每个图表应对应一个接口,前端通过Ajax获取数据后交给ECharts渲染。
面积与总价的关系用散点图最有表现力。这个图表对答辩特别有感染力,因为一眼就能看出“面积越大总价越高”里还藏着区域差异,比如核心城区小面积单价极高。画这个图时要注意横轴是面积,纵轴是总价,颜色或图例区分区域。ECharts配置如下:
javascript复制$.ajax({
url: '/api/scatter_data/',
type: 'get',
dataType: 'json',
success: function (res) {
var chart = echarts.init(document.getElementById('scatterChart'));
var option = {
title: { text: '面积与总价关系分布' },
xAxis: { type: 'value', name: '面积(平米)' },
yAxis: { type: 'value', name: '总价(万)' },
series: [{
type: 'scatter',
data: res.data,
symbolSize: 8
}]
};
chart.setOption(option);
}
});
区域均价对比用柱状图最直观,我按照区域分组,分别展示房源数量和平均单价。因为有可能会做横向对比,我建议柱状图按照均价排序,这样最高和最低一眼可见。另外可以做一个饼图展示户型分布,比如“3室2厅”占比最多、“1室1厅”占比最少,这个对用户了解市场结构很有帮助。
价格区间分布用饼图或者直方图都行。我建议先根据业务常识划分区间:100万以下、100-200万、200-300万、300-500万、500万以上,然后在Python里用区间函数Grouping后再统计数量。这个逻辑用纯SQL不容易写,用Python处理反而简单。
时间走势部分需要依赖created_at字段聚合出每日或每周发布的房源数量,这个在毕设数据量不大时可能看不出明显趋势,所以我通常不把它做成核心图表,而是做成页面上一个可选的补充模块。
4.3 系统部署与演示效果
因为毕设答辩通常需要现场演示,我把系统部署分成了两种情况来考量。
本地演示最省事:Python 3.9以上 + Django 4.x + MySQL,跑python manage.py runserver直接访问http://127.0.0.1:8000/即可。需要提醒的是,如果本地没有MySQL环境,直接用Django默认的SQLite也可以扛住几千条数据,答辩完全够用。我实测过5000条数据在SQLite上做聚合查询,返回时间基本在1秒以内,不会让现场显得卡顿。
如果想部署到云服务器上给老师远程看,最简单的是用宝塔面板来部署。大致步骤是:服务器装宝塔,安装Python项目管理器,然后上传项目代码,配置Python版本和依赖,添加站点绑定域名或IP,设置静态文件目录,最后重启服务。这个流程我实际操作过多次,稳定性很好,但是有一点要注意:settings.py里别忘了把ALLOWED_HOSTS改成['*'],否则部署上去会报DisallowedHost错误,这个坑我帮别人排查过好几次。
演示时我最推荐的操作路径是:先展示爬虫运行日志,证明“数据是自己采集的”;然后打开Admin后台,展示数据总量;再切到可视化大屏页面,展示各图表;最后点几个筛选条件,展示系统交互性。这样走下来,整个流程有数据、有代码、有展示、有交互,比光放PPT强太多了。
5. 常见问题与排查技巧实录
5.1 爬虫结果为空或返回403
这是大家问得最多的问题。爬虫抓不到数据,先不要怀疑代码,第一件事是检查响应状态码。可以直接在代码里打印resp.status_code和resp.text[:200]看看返回的是什么。如果返回403,说明被服务器拒绝访问了,最常见的原因是Headers不够完整。除了User-Agent,通常还需要Referer、Accept、Accept-Language等字段。
我在代码里把Headers定义成全局常量,并且写了一个会话复用工具,让爬虫模拟真实浏览器的请求头:
python复制import requests
def get_session():
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 ...',
'Accept': 'text/html,application/xhtml+xml,...',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': 'https://www.example.com/'
})
return session
如果加了完整Headers还是403,那就需要检查是不是触发了频率限制。这时把请求间隔调大到3到5秒,重新试试,通常能解决。还有些站点会用JavaScript动态渲染数据,这种情况通过Requests拿到的HTML里可能根本没有房源信息,需要改用Selenium或Playwright。但在毕设场景里,我建议优先找接口,很多房源的列表页其实有内部JSON数据接口,分析出真正的请求URL后,直接用Requests请求接口拿JSON,比解析HTML要稳定得多。这个思路在答辩时也能体现你的排查能力。
5.2 页面加载慢、图表数据量大卡顿
我遇到过一个同学做完全国几十个城市的爬虫,数据库里放了10万条二手房数据,结果前端图表接口每次都全量聚合,页面卡得打不开。原因很简单:后端一次性把所有数据查出来,再用Python循环统计,反复查数据库,性能自然就差了。
解决方案是善用Django ORM的聚合查询,让数据库完成统计,Python只做组织数据的工作。比如前文写的area_price_stats接口,用values().annotate()一步到位,不要遍历每一条记录去累加。另外一个细节是,对于不需要实时更新的聚合结果,可以加一层缓存,或者把统计结果写入独立的统计表,爬虫跑完触发一次统计更新,前端只读统计表。这样即使数据量到几十万,页面也依然丝滑。
还有一个容易忽略的点:ECharts渲染大量散点数据时,比如超过5000个点,可以用sampling参数开启降采样。加上series.sampling: 'lttb',ECharts会自己优化数据点,视觉上几乎无差别,性能却提升明显。
5.3 数据库中文乱码与字段解析异常
中文乱码问题在Windows本地开发时很常见,根本原因是数据库连接字符集没有设置好。如果你用的是MySQL,建库时要指定utf8mb4,连接时也要在Django的配置里设置:
python复制DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'house_db',
'USER': 'root',
'PASSWORD': 'your_password',
'HOST': '127.0.0.1',
'PORT': '3306',
'OPTIONS': {
'charset': 'utf8mb4',
}
}
}
我实际开发中遇到过的另一个情况是:某个字段解析出来是None,插入数据库时没做处理,直接报“Field 'unit_price' doesn't have a default value”。这里最稳妥的做法是在清洗阶段就把无法解析的字段设置为默认值,比如None或0,同时在模型里给字段设置null=True, blank=True,避免主键冲突。
有时候clean_total_price里的正则匹配会出现异常,比如“价格待定”这种文本,re.search(r'(\d+\.?\d*)', value)找不到数字就返回None。所以清洗函数里一定记得判断match is None的情况,否则你会在float()那一步踩到TypeError。
5.4 常见问题速查表
为了让大家更快定位问题,我把实际操作中常见的报错和解决方案整理成了一张表:
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 爬虫返回403 | 请求头不完整或触发反爬 | 补充完整Headers,降低请求频率,换代理或等待一段时间 |
| HTML里没有房源数据 | 数据由JavaScript动态渲染 | 改用Selenium/Playwright,或分析其内部数据接口直接请求JSON |
| 数据库插入时报唯一键冲突 | 重复采集了相同URL | INSERT ... ON DUPLICATE KEY UPDATE,或先查重再插入 |
| 图表接口返回慢 | 全量数据循环统计 | 改用Django ORM聚合查询,或增加统计缓存表 |
| 页面中文乱码 | 数据库字符集不一致 | 数据库和连接统一使用utf8mb4 |
| 部署后无法访问 | ALLOWED_HOSTS或端口问题 | 检查settings.py、云服务器安全组端口、nginx配置 |
| 后台管理中数据不显示 | 模型未注册到admin | 在admin.py中admin.site.register(HouseInfo) |
这个表看着简单,但几乎每一行我都帮人排查过。特别是“HTML里没有房源数据”这条,很多同学被卡住两三天,最后发现解析的HTML结构不对,或者是列表页结构变了。爬虫这个东西就是“今天能跑,明天不一定能跑”,所以你的解析逻辑要尽量写成容错性强的形式,多用if div:判断,少用容易抛异常的链式操作。
结个尾:一些实在话
这个项目从爬虫采集到数据可视化展示,走完整个流程大概需要一周到两周的时间,大部分时间其实消耗在解析网页和调试数据格式上,而不是写Django代码。我个人在做这个项目时最深的体会是:不要一开始就想着把所有功能做到完美,先跑通一条最简路径,比如先抓一个区域、1000条数据、两个图表,把整个链路打通,再逐步加功能和数据量。如果一上来就抓全站、写十个图表,出问题了很难定位。
另外想说一点稍偏但也很重要的话:做爬虫项目一定要有边界意识。采集频率要克制,数据量要控制在学习演示的合理范围内,不要对目标网站造成访问压力。你的目的是掌握技术原理和工程流程,而不是做一台无情的抓取机器。这个分寸感在答辩时,老师问到“你有没有考虑过对方网站的承受能力”时,也算是一个很加分的回答角度。
最后再分享一个小技巧:如果你希望这个项目在简历上有更好的竞争力,可以给系统加一个简单的“定时抓取”功能,用Django的django-crontab或者系统cron来定时执行爬虫脚本。这会让系统从“手动跑一次”升级为“自动化运行”,在毕设和面试里都是很亮眼的加分项。希望这篇拆解能给你一些实在的参考。
