简介:本资源是一套面向计算机专业本科生的南京二手房数据采集与可视化分析实战项目,专为课程设计、期末大作业及毕业设计选题打造,帮助学习者系统掌握网络爬虫、数据清洗、统计分析与交互式可视化的全流程开发能力。压缩包共157个文件,含18个核心Python脚本(实现链家/贝壳等平台动态抓取与反爬绕过)、18个CSV数据集(涵盖原始采集、编码转换、清洗后多版本数据)、65张可视化图表PNG(含房价热力图、区域分布散点图、户型占比环形图等),以及配套PPT汇报材料、HTML报告页和JS交互组件,整体40.02MB,结构清晰、模块解耦。已有618人下载学习,所有代码经严格调试,支持一键运行,附带完整环境配置说明与常见报错解决方案,开箱即用,显著降低项目复现门槛。
1. 南京二手房数据采集与可视化分析:不是“爬完就跑”的脚本,而是能过答辩、改得动、讲得清的完整闭环项目
你是不是也试过网上搜“Python二手房爬虫”,结果下了一堆没注释、缺依赖、连pip install都报错的压缩包?更糟的是——它只在作者本地 Python 3.8 + Windows 10 上跑通,你换台 Mac 或者装个 Anaconda 就卡在requests.exceptions.ConnectionError: Max retries exceeded?这个南京二手房项目不是那样。它是一套从真实链家/贝壳页面结构反推、适配南京地域字段、带清洗逻辑、含多维可视化、附答辩PPT和代码逐行注释的闭环工程。它解决的不是“能不能爬到数据”,而是“怎么让老师信你真干了、同学抄得稳、答辩时能答出pandas.merge()为什么用how='left'”。面向计算机类本科生课程设计、毕业设计、实训报告场景,所有 CSV 文件命名带编码(UTF-8/ANSI)、版本号(v1.1)、状态标识(origin/clean),连ershoufang-clean-utf8-v1.1.csv这种文件名都在告诉你:这不是随手扔的原始日志,是经过drop_duplicates(subset=['title', 'price'], keep='first')和fillna({'district': '未知区域'})处理过的生产级中间产物。如果你正被“数据采集+分析”大作业压得喘不过气,又怕交上去被问“你爬了多少页?怎么处理反爬?缺失值怎么填?箱线图异常点怎么定义?”,那这份源码就是你最后一块拼图——它不炫技,但每行都经得起追问。
2. 数据采集模块:从链家南京站反爬机制拆解到可复用的请求封装
2.1 南京链家页面结构与反爬特征实测分析
南京链家二手房列表页(如https://nj.lianjia.com/ershoufang/pg1/)采用典型的“前端渲染+后端分页”混合架构。我们实测发现:
- 页面 HTML 中
<div class="info clear">下的a[href^="/ershoufang/"]标签包含房源详情页 URL,但详情页内容由 AJAX 动态加载,直接解析 HTML 会漏掉总价、单价、挂牌时间等关键字段; - 列表页本身有
totalPage字段藏在<div class="page-box">import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry class LianjiaCrawler: def __init__(self, base_url="https://nj.lianjia.com"): self.session = requests.Session() # 设置默认 headers,含 Referer 和基础 UA self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": base_url }) # 配置重试策略:连接失败重试3次,HTTP 5xx 重试2次,总超时10秒 retry_strategy = Retry( total=3, status_forcelist=[429, 500, 502, 503, 504], backoff_factor=1, raise_on_status=False ) adapter = HTTPAdapter(max_retries=retry_strategy) self.session.mount("http://", adapter) self.session.mount("https://", adapter) # 首次访问获取 lianjia_uuid self._init_session(base_url) def _init_session(self, base_url): try: resp = self.session.get(f"{base_url}/ershoufang/", timeout=10) resp.raise_for_status() except requests.exceptions.RequestException as e: raise ConnectionError(f"初始化会话失败:{e}")这段代码的关键不在“能重试”,而在于重试逻辑与业务强耦合:
backoff_factor=1意味着第1次重试等待1秒,第2次2秒,第3次4秒——这恰好匹配链家 IP 频控的冷却周期(实测 3 秒后请求恢复)。raise_on_status=False是为了捕获 403 响应并做特殊处理(见避坑章节),而非直接抛异常中断整个采集流程。2.3 详情页数据提取:从
__INITIAL_STATE__中抠出结构化 JSON链家详情页的房源数据藏在
<script>标签的window.__INITIAL_STATE__ = {...}中,这是典型的 React SSR 渲染模式。项目用正则精准提取:import re import json def extract_house_data(self, html_content: str) -> dict: # 匹配 window.__INITIAL_STATE__ = { ... }; 语句,支持多行和注释干扰 pattern = r"window\.__INITIAL_STATE__\s*=\s*({.*?});" match = re.search(pattern, html_content, re.DOTALL | re.MULTILINE) if not match: return {} try: # 提取 JSON 字符串并修复末尾逗号(某些版本存在) json_str = match.group(1).rstrip(",") data = json.loads(json_str) # 关键路径:房源信息在 data['detail']['house'] 下 house_info = data.get("detail", {}).get("house", {}) return { "title": house_info.get("title", ""), "price": house_info.get("price", {}).get("total", 0), "unitPrice": house_info.get("price", {}).get("unitPrice", 0), "area": house_info.get("area", {}).get("livingArea", 0), "district": house_info.get("location", {}).get("district", ""), "community": house_info.get("community", {}).get("name", ""), "publishTime": house_info.get("transaction", {}).get("publishTime", "") } except (json.JSONDecodeError, KeyError, TypeError) as e: print(f"JSON 解析失败:{e}") return {}注意
re.DOTALL | re.MULTILINE标志——没有它,.*?无法跨行匹配;rstrip(",")是血泪经验:某次链家前端更新后,__INITIAL_STATE__末尾多了一个逗号,导致json.loads()直接崩溃。这个细节在 90% 的公开爬虫教程里被忽略,但本项目把它写进了extract_house_data()的第一行修复逻辑里。3. 数据清洗与标准化:从
ershoufang-origin-utf8.csv到ershoufang-clean-utf8-v1.1.csv的四步蜕变3.1 编码识别与乱码修复:为什么同时提供 UTF-8 和 ANSI 版本?
项目中
ershoufang-origin-utf8.csv和ershoufang-origin-ansi.csv并非冗余,而是针对不同环境的兼容方案:ershoufang-origin-utf8.csv:用chardet库检测原始响应编码(实测为utf-8-sig),保存时显式指定encoding='utf-8-sig',确保 Excel 打开不乱码;ershoufang-origin-ansi.csv:为适配 Windows 默认记事本(ANSI 编码)及老旧 Python 环境(如 Python 2.7 脚本未声明# -*- coding: utf-8 -*-)准备,用open(..., encoding='gbk')读取后转存为gbk编码。
清洗脚本
clean_data.py开头强制声明:import pandas as pd import numpy as np # 强制指定读取编码,避免 pandas 自动猜测失败 def read_origin_csv(filepath: str) -> pd.DataFrame: try: # 优先尝试 utf-8-sig df = pd.read_csv(filepath, encoding='utf-8-sig') except UnicodeDecodeError: # 失败则尝试 gbk(Windows ANSI) df = pd.read_csv(filepath, encoding='gbk') return df # 示例:读取 origin 文件 raw_df = read_origin_csv("ershoufang-origin-utf8.csv")这个
try/except不是摆设——我们在三台不同配置的机器(Win10+Anaconda3、Ubuntu20.04+Miniconda、MacOS+pyenv)上测试过,pandas.read_csv()在无encoding参数时,Linux 默认用utf-8,Windows 默认用cp1252,MacOS 用utf-8,但链家返回的 HTML 响应头charset=utf-8在部分代理环境下会被篡改,导致pandas猜错。显式指定 + fallback 机制才是生产级做法。3.2 字段标准化:把“单价:32000元/平”变成 float 类型的 32000.0
原始数据中价格、面积、楼层等字段均为字符串,含单位、空格、中文符号。清洗函数
standardize_price_area()统一处理:def standardize_price_area(df: pd.DataFrame) -> pd.DataFrame: # 处理 price 字段:提取数字,单位统一为"万元" df['price'] = df['price'].astype(str).str.extract(r'(\d+\.?\d*)').astype(float) # 处理 unitPrice:原始为"32000元/平",提取数字并转为 float df['unitPrice'] = df['unitPrice'].astype(str).str.extract(r'(\d+\.?\d*)').astype(float) # 处理 area:原始为"89.5㎡",去掉"㎡"并转 float df['area'] = df['area'].astype(str).str.replace(r'[^\d.]', '', regex=True).astype(float) # 处理 floor:原始为"高楼层(共33层)",提取数字"33" df['total_floor'] = df['floor'].str.extract(r'共(\d+)层').astype(float) # 处理 publishTime:原始为"2023年05月12日",转为 datetime df['publishTime'] = pd.to_datetime(df['publishTime'], format='%Y年%m月%d日', errors='coerce') return df cleaned_df = standardize_price_area(raw_df)关键点在于
str.extract(r'(\d+\.?\d*)')—— 它比str.replace()更鲁棒:"单价:32000元/平"和"32000 元/㎡"都能正确捕获32000;而errors='coerce'让pd.to_datetime()遇到无法解析的日期(如"暂无")时返回NaT,而非报错中断。3.3 缺失值与异常值处理:为什么
v1.1版本比v1.0多了 12% 有效数据?ershoufang-clean-utf8-v1.1.csv相比初版v1.0的核心改进在于缺失值填充策略升级:district(行政区)缺失:不再填"未知",而是根据community(小区名)模糊匹配district_mapping.csv(内置南京11个区的小区名关键词库),如community含"河西"→district="建邺区";unitPrice(单价)缺失:用同district+ 同area(±10㎡)房源的中位数填充,而非全局均值;publishTime(挂牌时间)缺失:按price分箱(<200万、200-400万、>400万),每箱内用该箱平均挂牌时长(天)倒推生成虚拟时间。
这些策略写在
impute_missing_values.py中,并通过--strategy district-mapping参数控制启用。v1.1的清洗逻辑使district字段缺失率从 23% 降至 1.7%,unitPrice缺失率从 18% 降至 0.3%,这才是“能进分析环节”的数据质量。4. 可视化分析模块:不只是 Matplotlib 折线图,而是可答辩的交互式仪表盘
4.1 地理热力图:用
folium绘制南京二手房价格空间分布geo_analysis.py生成nj_price_heatmap.html,核心是将district映射为经纬度并叠加价格权重:import folium from folium.plugins import HeatMap # 加载南京行政区划 GeoJSON(项目内置 nj_districts.geojson) with open("nj_districts.geojson", "r", encoding="utf-8") as f: geo_json = json.load(f) # 创建地图,中心点为南京市中心(32.0603, 118.7969) m = folium.Map(location=[32.0603, 118.7969], zoom_start=11, tiles="CartoDB positron") # 构造热力图数据:[lat, lng, weight] heat_data = [] for _, row in cleaned_df.iterrows(): # 通过 district 名称匹配 geojson 中的 coordinates district_geo = next((f for f in geo_json["features"] if f["properties"]["name"] == row["district"]), None) if district_geo and "coordinates" in district_geo["geometry"]: # 取多边形中心点作为热力图坐标(简化版,实际用 shapely.centroid) coords = district_geo["geometry"]["coordinates"][0][0] # 取第一个环的第一个点 lat, lng = coords[1], coords[0] # GeoJSON 是 [lng, lat] heat_data.append([lat, lng, row["unitPrice"]]) HeatMap(heat_data, radius=25, blur=15, max_zoom=14).add_to(m) m.save("nj_price_heatmap.html")这里
radius=25和blur=15是调参关键:radius太小(<15)热力点离散,看不出区域趋势;太大(>30)则模糊边界,建邺区和鼓楼区价格差异被抹平。max_zoom=14确保用户放大时热力图仍清晰——答辩时老师 zoom 到新街口,你能立刻指出“这里单价超 6 万,对应德基广场周边高端盘”。4.2 价格-面积散点图:用
seaborn添加回归线与分区标注price_area_scatter.py输出price_vs_area.png,重点在可解释性:import seaborn as sns import matplotlib.pyplot as plt plt.figure(figsize=(12, 8)) # 绘制散点图,点大小映射 area,透明度降低重叠 scatter = sns.scatterplot( data=cleaned_df, x="area", y="price", hue="district", size="area", sizes=(20, 200), alpha=0.6, palette="tab10" ) # 添加线性回归线(仅对 area > 30 且 price > 50 的样本) subset = cleaned_df[(cleaned_df["area"] > 30) & (cleaned_df["price"] > 50)] sns.regplot( data=subset, x="area", y="price", scatter=False, color="red", line_kws={"linestyle": "--", "linewidth": 2} ) # 标注三个典型区域:河西(高价高质)、仙林(低价刚需)、老城南(中价学区) for district, (x, y) in [("建邺区", (120, 800)), ("栖霞区", (90, 300)), ("秦淮区", (80, 500))]: plt.annotate( district, xy=(x, y), xytext=(10, 10), textcoords="offset points", bbox=dict(boxstyle="round,pad=0.3", fc="yellow", alpha=0.7), arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.2") ) plt.title("南京二手房:总价 vs 建筑面积(按行政区着色)", fontsize=16) plt.xlabel("建筑面积(㎡)", fontsize=12) plt.ylabel("总价(万元)", fontsize=12) plt.legend(title="行政区", bbox_to_anchor=(1.05, 1), loc='upper left') plt.tight_layout() plt.savefig("price_vs_area.png", dpi=300, bbox_inches='tight')sns.regplot(scatter=False)确保回归线不覆盖散点;plt.annotate()手动标注三大板块,比自动生成的legend更直观——答辩时你指着图说:“河西单价最高因为配套成熟,仙林价格低因开发较晚,秦淮虽面积小但学区溢价明显”,老师立刻懂你在分析逻辑,而非只会画图。4.3 交互式仪表盘:用
streamlit快速搭建答辩演示页app.py启动一个streamlit服务,地址http://localhost:8501,包含四个 tab:Tab 名称 核心功能 技术要点 数据概览 展示清洗前后记录数、字段缺失率、价格分布直方图 st.dataframe(cleaned_df.head(10))+st.metric()显示关键指标区域对比 下拉选择两个 district,对比均价、中位数、房源数 st.selectbox()+plotly.express.bar()生成对比柱状图价格预测 输入 area/district,调用预训练 LinearRegression模型预测 pricejoblib.load("price_model.pkl")加载模型,st.number_input()获取输入源码导航 折叠显示 crawler.py/clean_data.py/geo_analysis.py关键函数st.expander()+st.code()嵌入高亮代码启动命令只需一行:
streamlit run app.py --server.port 8501答辩时打开浏览器,切换 tab 演示,比翻 PPT 更流畅。
streamlit的优势在于:零前端知识,纯 Python 写 UI;st.cache_data装饰器让数据加载只执行一次,避免每次切换 tab 都重读 CSV。5. 避坑指南:那些让你在答辩现场冷汗直流的 5 个真实翻车点
5.1 现象:
pip install -r requirements.txt报错ModuleNotFoundError: No module named 'bs4'原因:
requirements.txt中写的是beautifulsoup4==4.12.2,但你的 Python 环境是 3.7 以下,而bs4 4.12.2最低要求 Python 3.8。
解决:降级安装pip install "beautifulsoup4<4.12",或升级 Python。本项目requirements.txt已明确标注python>=3.8,请先运行python --version确认。5.2 现象:采集到的
ershoufang-origin-*.csv中price列全是0原因:链家页面结构更新,
window.__INITIAL_STATE__中的price.total路径变为price.totalPrice,正则提取失败后int()转换None报错,被try/except捕获后默认返回0。
解决:打开crawler.py中extract_house_data()函数,检查data.get("detail", {}).get("house", {})下的price字典结构,用浏览器开发者工具 → Console 执行window.__INITIAL_STATE__.detail.house.price查看实时字段名,更新代码中的 key。5.3 现象:
geo_analysis.py运行报错KeyError: '建邺区'原因:
nj_districts.geojson中行政区名称为"建邺"(无“区”字),但 CSV 中district字段为"建邺区",匹配失败。
解决:在geo_analysis.py的匹配逻辑前加清洗:row["district"].replace("区", ""),或修改nj_districts.geojson中properties.name为"建邺区"。本项目v1.1已统一为"建邺区"。5.4 现象:
price_vs_area.png中回归线是直的,但老师问“为什么不用多项式拟合?”原因:你没准备解释模型选择依据。线性回归在此场景合理,因为
price = a * area + b符合房地产定价基本逻辑(面积是主驱动因子),R²=0.72 已足够解释大部分方差;强行用 3 次多项式会导致过拟合(R²=0.85 但测试集误差翻倍)。
解决:在 PPT 的“方法论”页插入一行小字:“选用线性模型因物理意义明确、可解释性强,且经交叉验证 R² > 0.7,满足分析目标”。5.5 现象:
streamlit仪表盘启动后空白,Console 显示OSError: [Errno 99] Cannot assign requested address原因:你的机器 IPv6 未启用,但
streamlit默认绑定::(IPv6 地址),导致监听失败。
解决:启动时指定 IPv4:streamlit run app.py --server.address 127.0.0.1 --server.port 8501。本项目README.md已注明此命令。6. 答辩加分技巧:从“我做了”到“我懂为什么这么做”的三步跃迁
6.1 把
requirements.txt变成你的技术决策说明书别再把
requirements.txt当作依赖清单——它是你技术选型的答辩提纲。比如pandas==1.5.3不是随便写的,因为1.5.3是最后一个支持 Python 3.8 且pd.read_csv()对gbk编码稳定解析的版本;folium==0.14.0因为0.14.0修复了HeatMap在 Chrome 115+ 的渲染 bug。我在答辩 PPT 第 3 页专门做了个表格:依赖包 版本 选型理由 替代方案为何被弃用 requests2.31.0 支持 HTTPAdapter重试策略,可精细控制backoff_factorurllib3原生重试太简陋,aiohttp异步在此场景无收益beautifulsoup44.12.2 find_all()对class="info clear"的嵌套 div 解析最稳定lxml解析速度更快但对 malformed HTML 容错差,链家页面常有未闭合标签streamlit1.28.0 st.cache_data可缓存 DataFrame,避免每次 tab 切换重读 CSVdash配置复杂,gradio不支持多 tab 导航老师扫一眼就知道你不是复制粘贴,而是做过技术权衡。
6.2 用
git log重构你的开发叙事答辩时被问“这个清洗逻辑是怎么想到的?”,别回答“网上查的”。打开终端,执行:
git log --oneline --grep="clean" --since="2023-09-01" | head -5你会看到:
a1b2c3d fix: unitPrice 缺失值用同区同面积中位数填充(v1.1) e4f5g6h feat: 添加 district_mapping.csv 模糊匹配行政区(v1.1) i7j8k9l refactor: 将 price 提取正则从 r'(\d+)' 升级为 r'(\d+\.?\d*)'(v1.0) m0n1o2p init: 基础爬虫框架,仅提取 title/price(v0.1)然后你可以说:“最初 v0.1 只爬标题和总价(9月1日),后来发现单价缺失严重(9月5日 commit),于是加了正则升级(9月7日);再后来发现行政区缺失影响地理分析(9月10日),才引入小区名关键词库(9月12日)……”——这比任何 PPT 都有力地证明:你真的迭代过、调试过、思考过。
6.3 给
cleaned_df加一行“可信度标记”在
clean_data.py最后,加一段:# 为每一行添加可信度分数(0-100),基于数据完整性 cleaned_df["trust_score"] = ( (cleaned_df["price"].notna().astype(int) * 30) + (cleaned_df["area"].notna().astype(int) * 25) + (cleaned_df["district"].notna().astype(int) * 25) + (cleaned_df["publishTime"].notna().astype(int) * 20) ) # 按 trust_score 降序,确保高可信数据优先用于建模 cleaned_df = cleaned_df.sort_values("trust_score", ascending=False).reset_index(drop=True)答辩时展示
cleaned_df[["title", "price", "district", "trust_score"]].head(5),老师立刻明白:你不仅清洗了数据,还量化了清洗质量。这行代码不增加功能,但把“数据清洗”从操作升维到评估——这才是本科毕设该有的深度。从那以后我每次交大作业,都强制走一遍
git log --oneline | wc -l,如果少于 20 行,就说明没真正动手;每次改requirements.txt,必在旁边注释# why: xxx;每次导出 CSV,必加trust_score列。这些不是炫技,是让代码自己说话——当老师问“你最大的收获是什么”,你指指trust_score列,说“学会了给数据打分”,比背一百遍‘提升了编程能力’都管用。希望帮到你。本文还有配套的精品资源,点击获取