☰
二手房数据采集与可视化分析:Python全链路实战指南
2026/9/26 14:04:54 网站建设 项目流程

简介:本资源为基于Python的二手房数据采集及可视化分析毕业设计项目完整资料包,面向计算机、通信、人工智能、自动化等相关专业的学生与教师,可用于毕业设计、期末课程设计或课程大作业。项目为个人毕设成果,答辩评审分达98分,代码均经过调试测试,确保可运行,适合小白学习与进阶。压缩包共157个文件,约40.01MB,包含18个py源码文件、18个csv数据集、15个html页面、11个js脚本及65个png图表等,覆盖数据采集、清洗、存储与可视化全流程,并附论文资料。已有177人学习下载。读者可获得完整可运行的赛题方案、原始与清洗后的二手房数据、可视化图表与网页展示代码,以及论文写作参考,基础较好者还可在此基础上修改调整,实现不同功能,具有较高的学习借鉴价值。

1. 二手房数据采集与可视化:从标题到可跑通的完整链路

二手房数据采集及可视化分析这个题目,在计算机毕业设计里出现的频率极高,但真正能跑通、能答辩、能写进论文的项目并不多。原因不复杂:大部分人卡在采集环节就被反爬拦住了,或者采下来一堆脏数据不知道怎么清洗,最后可视化只能拿几条假数据凑数。这个项目要解决的核心问题就三件事——把真实房源数据稳定采下来、把脏数据洗成能分析的格式、用图表把价格分布和影响因素讲清楚。适合谁做?Python基础一般、但愿意照着步骤一步步跑通的本科生,也适合想快速搭一个数据管线原型的初级工程师。整条链路我建议用 requests + BeautifulSoup 做采集,pandas 做清洗,pyecharts 或 matplotlib 做可视化,Flask 或 Streamlit 做展示层。下面按实际落地顺序拆开讲。

2. 采集层:用 requests 和解析库把房源列表稳定拿下来

2.1 为什么选 requests + BeautifulSoup 而不是 Scrapy

毕业设计场景下,Scrapy 的工程化程度确实更高,但它带来的学习成本对多数人来说是负收益。你需要理解 Twisted 异步模型、中间件优先级、Item Pipeline 的生命周期,这些概念在答辩时被追问的概率远大于你实际用到的深度。requests + BeautifulSoup 的组合足够覆盖二手房列表页和详情页的采集需求,代码量少、调试直观、出问题容易定位。

常见做法是:列表页负责翻页拿房源 ID 和详情链接,详情页负责拿具体字段。我一般会把这两步拆成两个函数,中间用一个队列或列表传递 URL,这样单页调试和批量跑互不影响。

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", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_list_page(city, page): """抓取列表页,返回详情页链接列表""" url = f"https://example.com/{city}/ershoufang/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") links = [] for tag in soup.select(".sellListContent .title a"): href = tag.get("href") if href: links.append(href) return links def fetch_detail(url): """抓取详情页,返回结构化字段""" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") data = { "title": soup.select_one(".sellDetailHeader .main").get_text(strip=True) if soup.select_one(".sellDetailHeader .main") else "", "total_price": soup.select_one(".price .total").get_text(strip=True) if soup.select_one(".price .total") else "", "unit_price": soup.select_one(".unitPrice .unitPriceValue").get_text(strip=True) if soup.select_one(".unitPrice .unitPriceValue") else "", "area": soup.select_one(".base .area .mainInfo").get_text(strip=True) if soup.select_one(".base .area .mainInfo") else "", } return data

这段代码的逻辑很直白:fetch_list_page负责翻页并提取详情链接,fetch_detail负责从详情页里按 CSS 选择器抠字段。参数上最需要关注的是timeout和HEADERS——超时设 10 秒是经验值,太短容易误判失败,太长会拖慢整体节奏;User-Agent 必须带,否则很多站点直接返回 403。resp.encoding = "utf-8"这行不能省,部分站点不声明编码,不手动指定会出现中文乱码。

2.2 翻页策略与请求频率控制

翻页本身不难,难的是让请求看起来不像机器。我的做法是每抓一页 sleep 一个随机区间,比如 2 到 5 秒,同时在抓够一定页数后换一次 User-Agent。这不是为了对抗什么复杂机制,纯粹是降低被临时限制的概率。

def crawl(city, max_page=50): all_data = [] for page in range(1, max_page + 1): links = fetch_list_page(city, page) if not links: break for link in links: try: item = fetch_detail(link) item["city"] = city all_data.append(item) except Exception as e: print(f"failed: {link}, reason: {e}") time.sleep(random.uniform(2, 5)) print(f"page {page} done, total {len(all_data)}") return all_data

max_page设 50 是一个保守值,实际能抓到多少取决于站点结构。random.uniform(2, 5)控制请求间隔,这个区间是我试过比较稳的——低于 2 秒容易被限,高于 5 秒采集效率太低。异常捕获那层不要省,单条失败不应该中断整个任务。

注意:采集前先确认目标站点的 robots.txt 和使用条款,毕业设计建议用公开的、允许采集的数据源,或者用平台提供的开放接口。

3. 清洗层:把脏数据变成能分析的 DataFrame

3.1 字段标准化:价格、面积、楼层怎么统一

采下来的原始数据几乎不能直接用。价格字段带「万」字,面积带「平米」,楼层是「中楼层(共18层)」这种混合文本。清洗的核心思路是把所有数值字段拆成纯数字加单位,再统一量纲。

import pandas as pd import re def parse_price(text): """'320万' -> 320.0""" if not text: return None m = re.search(r"(\d+\.?\d*)", text) return float(m.group(1)) if m else None def parse_area(text): """'89.5平米' -> 89.5""" if not text: return None m = re.search(r"(\d+\.?\d*)", text) return float(m.group(1)) if m else None def parse_floor(text): """'中楼层(共18层)' -> ('中', 18)""" if not text: return None, None level_map = {"低": 1, "中": 2, "高": 3} level = None for k, v in level_map.items(): if k in text: level = v break total = None m = re.search(r"共(\d+)层", text) if m: total = int(m.group(1)) return level, total df = pd.DataFrame(raw_data) df["total_price"] = df["total_price"].apply(parse_price) df["area"] = df["area"].apply(parse_area) df["floor_level"], df["floor_total"] = zip(*df["floor"].apply(parse_floor)) df["unit_price_calc"] = (df["total_price"] * 10000 / df["area"]).round(0)

parse_price和parse_area都用正则提取第一个数字,这是最稳的做法,因为不同站点的单位写法不统一。parse_floor把楼层拆成两个维度:等级(低/中/高映射成 1/2/3)和总层数,这样后续可以做「楼层等级对单价的影响」分析。最后一行用总价和面积反算单价,用来和页面标注的单价做交叉验证——如果两者差距超过 10%,说明这条数据大概率有问题。

3.2 缺失值与异常值处理

清洗完字段后,下一步是处理缺失和异常。缺失值分两种:关键字段缺失(价格、面积)直接丢弃;非关键字段缺失(楼层、朝向)用众数或「未知」填充。异常值用 IQR 方法识别,超出 1.5 倍四分位距的单价记录标记为可疑。

# 关键字段缺失直接丢弃 df = df.dropna(subset=["total_price", "area"]) # 非关键字段填充 df["floor_level"] = df["floor_level"].fillna(df["floor_level"].mode()[0]) df["floor_total"] = df["floor_total"].fillna(df["floor_total"].median()) # IQR 异常值标记 q1 = df["unit_price_calc"].quantile(0.25) q3 = df["unit_price_calc"].quantile(0.75) iqr = q3 - q1 lower = q1 - 1.5 * iqr upper = q3 + 1.5 * iqr df["is_outlier"] = (df["unit_price_calc"] < lower) | (df["unit_price_calc"] > upper) # 分析时排除异常值 df_clean = df[~df["is_outlier"]].copy() print(f"原始 {len(df)} 条,清洗后 {len(df_clean)} 条,剔除 {len(df) - len(df_clean)} 条")

IQR 的系数用 1.5 是标准做法,不需要调。is_outlier这列保留下来,论文里可以专门写一节分析异常房源的特征——比如某些单价特别高的可能是学区房或别墅,这本身就是有价值的分析点,不要直接删掉。

4. 可视化层:用 pyecharts 把价格分布和影响因素讲清楚

4.1 价格分布:直方图 + 箱线图组合

可视化不是把数据随便画成图就完事,每张图要回答一个具体问题。价格分布这张图要回答的是「这个城市的二手房单价集中在什么区间,长尾有多长」。直方图看整体形态,箱线图看分区域或分户型的中位数差异。

from pyecharts import options as opts from pyecharts.charts import Histogram, Boxplot, Grid import numpy as np # 直方图 prices = df_clean["unit_price_calc"].tolist() hist = ( Histogram() .add_xaxis([str(int(b)) for b in np.histogram_bin_edges(prices, bins=30)]) .add_yaxis("房源数量", np.histogram(prices, bins=30)[0].tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="二手房单价分布"), xaxis_opts=opts.AxisOpts(name="单价(元/平米)"), yaxis_opts=opts.AxisOpts(name="房源数量"), ) ) hist.render("price_hist.html")

bins=30是经验值,数据量在几百到几千条之间时,30 个分箱能看出分布形态又不会太碎。如果数据量超过一万条,可以调到 50。np.histogram_bin_edges自动计算分箱边界,比手动指定更省事。

4.2 影响因素:用散点图和分组柱状图对比

面积、楼层、朝向对单价的影响,用散点图看面积和单价的关系最直观,用分组柱状图看不同楼层等级的平均单价差异。

from pyecharts.charts import Scatter scatter = ( Scatter() .add_xaxis(df_clean["area"].round(1).tolist()) .add_yaxis("单价", df_clean["unit_price_calc"].tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="面积与单价关系"), xaxis_opts=opts.AxisOpts(name="面积(平米)", type_="value"), yaxis_opts=opts.AxisOpts(name="单价(元/平米)"), ) ) scatter.render("area_price_scatter.html") # 楼层等级平均单价 floor_avg = df_clean.groupby("floor_level")["unit_price_calc"].mean().round(0) print(floor_avg)

散点图里如果点太多,可以加symbol_size=5把点缩小,或者用opts.ScatterOpts开启透明度。楼层等级的平均单价用groupby一行就能算出来,论文里可以直接把这个结果做成表格。

提示:pyecharts 生成的 HTML 文件可以直接嵌入 Flask 模板或 Streamlit 页面,不需要额外转换。

5. 避坑与排查:采集和分析中最容易翻车的 5 个点

5.1 请求返回 200 但内容为空

现象:resp.status_code是 200,但soup.select拿不到任何元素。原因通常是站点返回了验证页面或空壳页面,真正的数据通过 JavaScript 动态加载。解决:先打印resp.text[:500]看返回内容,如果是验证页就换请求头或降低频率;如果是动态加载,改用 Selenium 或直接找数据接口。

5.2 中文乱码导致解析失败

现象:抓下来的标题和地址全是乱码,正则匹配不到中文。原因是resp.encoding没有正确设置,requests 默认用 ISO-8859-1。解决:手动设resp.encoding = resp.apparent_encoding或直接指定"utf-8",设完再解析。

5.3 清洗后数据量骤减

现象:采了 2000 条,清洗完只剩 300 条。原因通常是dropna把大量记录干掉了,而缺失的根源在采集阶段——某个字段的选择器写错了,导致整列都是空。解决:清洗前先df.isnull().sum()看每列缺失比例,超过 50% 缺失的列回去检查采集代码。

5.4 可视化图表中文显示为方块

现象:matplotlib 生成的图表里中文全是方框。原因是默认字体不支持中文。解决:在绘图前设plt.rcParams["font.sans-serif"] = ["SimHei"]和plt.rcParams["axes.unicode_minus"] = False。pyecharts 不存在这个问题,它用 HTML 渲染。

5.5 论文里数据对不上

现象:论文里写的房源数量和代码跑出来的不一致。原因通常是多次运行采集脚本,数据文件被覆盖或追加了重复记录。解决:每次采集输出带时间戳的文件名,清洗前先df.drop_duplicates(subset=["title", "total_price"])去重。

6. 进阶技巧:用 Streamlit 把分析结果做成可交互的展示页

答辩时如果只放几张静态图,评委很容易走神。用 Streamlit 把清洗后的数据和图表串成一个可交互页面,现场筛选区域、拖动价格区间,图表实时更新,效果完全不一样。Streamlit 的优势是不需要写前端代码,一个 Python 文件就能跑起来。

import streamlit as st import pandas as pd import plotly.express as px st.set_page_config(page_title="二手房数据分析", layout="wide") st.title("二手房数据可视化分析") @st.cache_data def load_data(): return pd.read_csv("clean_data.csv") df = load_data() # 侧边栏筛选 st.sidebar.header("筛选条件") price_range = st.sidebar.slider( "单价区间(元/平米)", int(df["unit_price_calc"].min()), int(df["unit_price_calc"].max()), (int(df["unit_price_calc"].quantile(0.1)), int(df["unit_price_calc"].quantile(0.9))), ) area_range = st.sidebar.slider( "面积区间(平米)", float(df["area"].min()), float(df["area"].max()), (float(df["area"].quantile(0.1)), float(df["area"].quantile(0.9))), ) mask = ( df["unit_price_calc"].between(*price_range) & df["area"].between(*area_range) ) filtered = df[mask] col1, col2 = st.columns(2) with col1: fig1 = px.histogram(filtered, x="unit_price_calc", nbins=30, title="单价分布", labels={"unit_price_calc": "单价(元/平米)"}) st.plotly_chart(fig1, use_container_width=True) with col2: fig2 = px.scatter(filtered, x="area", y="unit_price_calc", title="面积与单价关系", opacity=0.5, labels={"area": "面积(平米)", "unit_price_calc": "单价(元/平米)"}) st.plotly_chart(fig2, use_container_width=True) st.metric("筛选后房源数量", len(filtered)) st.metric("平均单价", f"{filtered['unit_price_calc'].mean():.0f} 元/平米")

@st.cache_data装饰器让数据只加载一次,筛选时不会重复读 CSV。st.sidebar.slider的默认区间用 10% 和 90% 分位数,避免极端值把滑块范围拉得太大。st.columns(2)把两张图并排显示,页面利用率更高。use_container_width=True让图表自适应容器宽度,不用手动调尺寸。

跑起来只需要在终端执行streamlit run app.py,浏览器会自动打开页面。部署到服务器上,答辩时直接给评委一个链接,比放 PPT 里的截图有说服力得多。

这个方案我从采集到展示完整跑过一遍,最大的教训是:不要等到采集全部跑完才开始清洗,先拿 50 条数据把清洗和可视化链路走通,确认字段解析没问题再放量跑。否则采了几千条发现某个字段解析全错,返工的时间成本非常高。希望帮到你。

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

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

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

立即咨询