基于Python的二手房数据爬取与可视化分析系统实践
2026/9/16 16:03:04 网站建设 项目流程

简介:这是一份面向Python初学者与高校毕业生的二手房数据爬取与可视化分析项目,兼顾网络爬虫、数据清洗与图表展示,既适合课程设计参考,也可作为论文配套源码。资源包共190个文件,总大小约40.05MB,以Python脚本、CSV数据表、HTML可视化页面、JS交互脚本、INI配置及PPT演示文稿为主,同时包含大量运行截图与项目备份,层次清晰、便于查阅。目前已有45人学习下载。项目主体由多轮测试通过的爬虫代码与可视化模块组成,数据文件覆盖原始抓取、UTF-8/ANSI编码及清洗后多版本,配套演示文稿可直接用于毕业答辩或汇报,能够帮助读者快速掌握二手房数据采集与分析的完整实战流程。

1. 二手房数据系统不是爬虫课:先想清楚你要交付什么

拿到“基于 Python 的二手房数据爬取与可视化分析系统”这个题目,第一反应通常是去写爬虫抓链家,但实际做过的人都知道,爬虫只占整个系统三成工作量。真正决定这个毕设或项目能不能过审的,是数据清洗是否可靠、分析图表是否能回答问题、以及 Web 端能不能把结果串起来现场演示。这个题目的本质是一条数据流水线:从目标网站采集二手房列表与详情字段,清洗成结构化表格存入本地数据库,再用图表展示价格分布、区域对比和户型规律,最后用一个 Flask 页面把这些能力暴露出来。适合的人群是正在选毕设题的本科生、想练全栈数据流程的 Python 初学者,以及需要给团队做数据看板雏形的开发。下面按我实际搭这类系统的顺序来讲,先定字段和反爬策略,再做清洗和入库,再做图表,最后包成 Web 系统。

2. 用 Python 爬取链家二手房:数据源选型与解析模块设计

2.1 先确定字段清单,再写爬虫代码

爬虫最容易犯的错是边写边加字段,导致清洗阶段反复返工。我一般会先打开链家的二手房列表页看一眼页面结构,把需要的字段固定下来。城市不同,URL 规则略有差别,但列表页和详情页的信息分布是一致的,列表页就能拿到大部分核心字段,详情页补客厅图片数量、小区成交记录这类次要信息。

字段来源页面原始格式清洗后类型用途
标题列表页.title a文本string房源描述
小区列表页.positionInfo a文本string分组统计
区域列表页.positionInfo“区域-板块”string区域均价
总价列表页.totalPrice span“345万”float总价分布
单价列表页.unitPrice span“45000元/平”int核心分析指标
面积列表页.houseInfo“89.5平米”float单价校验
朝向列表页.houseInfo“南 北”string朝向偏好分析
装修列表页.houseInfo“精装”string装修对价格影响
楼层列表页.houseInfo“低楼层/共6层”string楼层对比
关注人数列表页.followInfo“30人关注”int热度代理变量
发布时间列表页.followInfo“5天前发布”string数据新鲜度
唯一编码列表页 链接后缀stringstring去重主键

这套字段清单对应的就是一个house表的 schema,后面所有清洗、分析和页面展示都围绕它展开。做毕设时,把这张表直接放进论文的“数据设计”一节,是很好的加分项。

2.2 用 requests 和 BeautifulSoup 解析列表页的最小实现

链家的列表页是服务端直接渲染的 HTML,不需要处理 Ajax 动态拼接,这也是我推荐它在毕设里出现的原因。bs4 解析动态页面通常是先拿到 XHR 接口再解析 JSON 的思路,用在这类静态页面上属于多余操作,反而把链路拉长。下面是爬取单个列表页并解析字段的最小代码。

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 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Referer": "https://bj.lianjia.com/", } def parse_list_page(page: int) -> list[dict]: url = f"https://bj.lianjia.com/ershoufang/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "lxml") items = [] for li in soup.select("li.clear"): title_node = li.select_one(".title a") if not title_node: continue link = title_node.get("href", "") # 详情页链接,后8位是房源编码 code = link.rstrip("/").split("/")[-1] house_info = li.select_one(".houseInfo") position_info = li.select_one(".positionInfo") total_node = li.select_one(".totalPrice span") unit_node = li.select_one(".unitPrice span") follow_node = li.select_one(".followInfo") info_parts = house_info.get_text(strip=True).split(" | ") if house_info else [] position_parts = position_info.get_text(strip=True).split(" | ") if position_info else [] follow_parts = follow_node.get_text(strip=True).replace(" ", "") if follow_node else "" items.append({ "code": code, "title": title_node.get_text(strip=True), "小区": position_parts[0] if position_parts else "", "区域": position_parts[1] if len(position_parts) > 1 else "", "总价": total_node.get_text(strip=True) if total_node else "0", "单价": unit_node.get_text(strip=True) if unit_node else "0", "面积": info_parts[0] if info_parts else "0", "朝向": info_parts[1] if len(info_parts) > 1 else "", "装修": info_parts[2] if len(info_parts) > 2 else "", "楼层": info_parts[3] if len(info_parts) > 3 else "", "关注": follow_parts.split("人关注")[0] if "人关注" in follow_parts else "0", "发布": follow_parts.split("人关注")[-1] if "人关注" in follow_parts else "", }) return items if __name__ == "__main__": data = parse_list_page(1) print(len(data), data[0] if data else None)

代码里有两个关键点:一是li.clear是链家列表页每个房源卡片的最外层容器,房源页改版后依然保留了这个 class,兼容性比直接找div.info好;二是houseInfofollowInfo这类字段是短横线分隔的模板文本,直接用" | "切分可以稳定拿到面积、朝向、楼层等子字段。如果你在 PyCharm 里单步调试,会发现某些房源的positionInfo只有一个元素,所以代码里对长度做了判断,避免切片越界。字段缺失时给空字符串而不是跳过房源,能保住样本量,清洗时再统一处理缺失值。

2.3 频率控制、UA 轮换与异常重试

写爬虫不得不在“拿全数据”和“别给目标站造成压力”之间取平衡。常见做法是每页抓取之间随机睡眠 1 到 3 秒,并把请求头中的 User-Agent 伪装成主流浏览器的版本。还可以加一个简单的重试装饰器,遇到ConnectionError或 5xx 时最多重试 3 次,而不是让整个程序中断。这里的核心思想是让爬虫具备断点续跑能力,抓完的页码记录到本地文件里,下次启动自动跳过。

import logging from functools import wraps logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") def retry(max_retries: int = 3, delay: float = 2.0): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except requests.RequestException as exc: logging.warning("请求失败: %s, 第 %d 次重试", exc, attempt + 1) time.sleep(delay * (attempt + 1)) logging.error("重试 %d 次后仍然失败,页码参数: %s", max_retries, args) return [] return wrapper return decorator @retry(max_retries=3) def fetch_html(url: str): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.text

重试装饰器中delay * (attempt + 1)是线性退避,第一次等 2 秒,第二次等 4 秒,够应对大部分瞬时错误。日志里必须记录页码参数,否则断点续跑时你不知道崩在哪一页。这里提醒一句:毕设爬虫尽量只跑一到两个城市的少量房源,不要全站爬,也不要用并发加速。链家对短时间高频请求的封禁策略很直接,封了你这个 IP 之后所有请求返回 302 跳转验证页,届时只能等几小时甚至一天。

3. Python 数据清洗与落库:把字符串字段变成可分析的房价指标

3.1 用 pandas 统一单位与类型

抓下来的数据是一个字符串嵌套字典的列表,直接用 DataFrame 读取后,总价、单价、面积都是文本型,不能参与计算。清洗的第一步是去单位:总价的“万”字要去掉并转成 float,单价的“元/平”要去掉并转成 int,面积的“平米”要去掉并转成 float。这里最容易踩的坑是链家部分房源单价字段会出现“暂无数据”这类占位文本,不能一刀切用astype,而要用pd.to_numeric配合errors="coerce",让无法转换的值变成 NaN。

import pandas as pd import numpy as np def clean_house_df(raw_items: list[dict]) -> pd.DataFrame: df = pd.DataFrame(raw_items) df["总价"] = pd.to_numeric( df["总价"].astype(str).str.replace("万", ""), errors="coerce" ) df["单价"] = pd.to_numeric( df["单价"].astype(str).str.replace("元/平", ""), errors="coerce" ) df["面积"] = pd.to_numeric( df["面积"].astype(str).str.replace("平米", ""), errors="coerce" ) df["关注"] = pd.to_numeric(df["关注"], errors="coerce") df = df.replace("", np.nan) return df

astype(str)是防止个别原始值已经是数字时报错,str.replace会作用于整列文本。注意“单价”和“总价”在这套数据里是两套口径:总价是挂牌价,单价是房源单价,二者理论上应该满足“总价约等于面积乘单价”,但有小部分房源会因地下室、车位等情况偏离,分析时可以保留,展示时建议过滤掉偏离超过 50% 的异常行,避免散点图被极端值撑爆。

3.2 去重与缺失值填充策略

房源唯一编码code是天然的主键,直接用它去重最可靠。列表页翻页时偶尔会出现同一套房源重复出现在不同页的情况,这是正常的排序机制导致的,不处理会虚增样本量。缺失值处理上,朝向和装修缺失的房源可以直接填充为“未知”,面积或单价缺失的行建议删除,因为这两个字段是所有分析图表的基础,补出来的值没有意义。楼层字段在区域均价分析中用不到,可以保留原样作为备选。

def dedup_and_fill(df: pd.DataFrame) -> pd.DataFrame: df = df.drop_duplicates(subset=["code"], keep="first") df = df.dropna(subset=["总价", "单价", "面积"]) df["朝向"] = df["朝向"].fillna("未知") df["装修"] = df["装修"].fillna("未知") return df

去重时keep="first"意味着重复房源只保留第一次出现的那条记录,后续翻页遇到同一编码直接忽略。dropna删除的是关键指标缺失的房源,这套逻辑和论文里的“数据预处理”章节是直接对应的,答辩时老师问为什么样本量和链家页面显示的不一致,就回答“剔除缺失关键字段的记录后保证统计口径一致”。

3.3 存到 SQLite:CSV 只适合中间态

爬虫和清洗的结果如果只存成 CSV,可视化模块每次启动都要重新读整个文件,而且 Web 系统多用户访问时不安全。SQLite 是零配置的嵌入式数据库,Python 标准库自带sqlite3,不需要安装额外服务。把 DataFrame 直接写入 SQLite 的常见做法是用pandas.to_sql,首次建表,后续按code做唯一约束去重。要注意的是to_sql默认不会创建索引,数据量超过几千条后按区域查询会明显变慢,所以要手动建唯一索引。

import sqlite3 def save_to_sqlite(df: pd.DataFrame, db_path: str = "house.db"): conn = sqlite3.connect(db_path) df.to_sql("house", conn, if_exists="append", index=False) conn.execute(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_house_code ON house(code) """) conn.execute(""" DELETE FROM house WHERE rowid NOT IN ( SELECT MIN(rowid) FROM house GROUP BY code ) """) conn.commit() conn.close()

这里先append写入了可能重复的数据,再用MIN(rowid) GROUP BY code删除重复项,两步操作保证幂等性。rowid是 SQLite 默认的行标识,删除时不会影响其他数据。这种方式比抓取前查一次库再决定是否插入来得稳,因为嵌套循环里反复读数据库会让爬虫变慢一倍。

4. Python 可视化分析:用统计图表回答“房价贵在哪”

4.1 先画单价面积散点图,识别异常值

数据处理完的第一件事永远是画散点图,而不是直接算均值。单价和面积的关系能直观暴露两类问题:一是面积极小但单价极高的车位或储物间,二是面积和总价明显不匹配的房源码。用 pyecharts 的 Scatter 图,鼠标悬停能看到房源详情,适合做交互式演示。

from pyecharts.charts import Scatter from pyecharts import options as opts def scatter_price_area(df: pd.DataFrame, output: str = "scatter_price_area.html"): sample = df.sample(min(800, len(df)), random_state=42) scatter = ( Scatter() .add_xaxis(sample["面积"].round(1).tolist()) .add_yaxis( "房源", sample["单价"].tolist(), symbol_size=6, label_opts=opts.LabelOpts(is_show=False), ) .set_global_opts( title_opts=opts.TitleOpts(title="二手房面积与单价分布"), xaxis_opts=opts.AxisOpts(name="面积(平米)"), yaxis_opts=opts.AxisOpts(name="单价(元/平)"), ) ) scatter.render(output)

sample做了 800 条上限的随机抽样,因为浏览器渲染超过 2000 个点时交互会卡顿,而散点图的分布形态在 800 个点下已经足够清晰。random_state=42固定抽样种子,保证论文里的图可以复现。如果你发现图中出现“面积 200 平单价只有 1 万”的左上角点,多半是商住两用房或小产权,分析时可以单独打标。

4.2 区域均价柱状图和朝向箱线图

柱状图是最容易让老师看懂区域差异的方式。先按“区域”分组求单价均值,再按均价降序排列,过滤掉样本量少于 3 的区域,避免个别房源拉高整个区域均价。朝向分析更推荐箱线图,因为各朝向的房价分布宽窄不同,均值容易掩盖“朝南不一定最贵,但一定最稳定”这类规律。

from pyecharts.charts import Bar def region_bar(df: pd.DataFrame, output: str = "region_price.html"): grouped = ( df.groupby("区域")["单价"] .agg(["mean", "count"]) .query("count >= 3") .sort_values("mean", ascending=False) ) bar = ( Bar() .add_xaxis(grouped.index.tolist()) .add_yaxis("平均单价", grouped["mean"].round(0).tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="各区域二手房平均单价"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=30)), ) ) bar.render(output)

agg(["mean", "count"])同时求出均值和样本量,query("count >= 3")在分组之后执行过滤,比先过滤再分组更简洁。rotate=30是长区域名显示时的常用配置,否则文字重叠。如果柱状图里出现一个你完全没听过的高价板块,不用急着删数据,先回数据库查一下该板块的房源是否集中在某个高端楼盘,这是很好的分析结论素材。

4.3 把多张图拼成一个 HTML 报告

逐个渲染 HTML 的缺点是要开多个标签页演示,现场答辩时很容易切乱。pyecharts 支持用Page容器把多个图放进同一个 HTML 文件,并保留每个图的独立交互能力。这里推荐使用Page(layout=Page.SimplePageLayout)的纵向排列布局,各图独立滚动,不会互相挤压。

from pyecharts.charts import Page def build_report(scatter, bar, box, output: str = "report.html"): page = Page(layout=Page.SimplePageLayout) page.add(scatter, bar, box) page.render(output)

Page 容器本质上是在一个 HTML 文件里放了多个div容器,每个图表都是独立的 ECharts 实例,点击和缩放互不干扰。演示时给每张图配上标题和小结文字,老师问起来你能指着图说出“单价在 4 万以下的房源集中在哪三个区域”这种话,视觉效果远好于干巴巴读数据。

5. 用 Flask 把爬虫和分析结果串成一个可演示系统

5.1 目录结构与模块边界

Web 端不建议直接在爬虫脚本里挂 Flask,模块职责要分开。推荐结构是spider/放爬虫和清洗,analysis/放图表生成,web/放 Flask 路由和模板,data/放 SQLite 数据库。这样论文里写“系统采用分层架构”时能被实例支撑,答辩时改一个模块也不会牵连其他部分。

目录文件职责
spider/fetcher.py, parser.py, cleaner.py请求、页面解析、清洗入库
analysis/charts.py, report.py统计计算、图表渲染、报告组装
web/app.py, templates/Flask 路由、页面模板、静态资源
data/house.dbSQLite 数据库文件
config.py城市、页码范围、请求间隔爬虫参数统一配置

我习惯把数据库路径和爬虫页数范围放到config.py,因为临时改城市和页数是高频操作。频繁改代码文件里硬编码的 URL 会在答辩现场留下不稳定印象,配置化还有一个好处:论文里可以把配置表原样贴成表格。

5.2 页面路由和 AJAX 请求的最小实现

Flask 端做两件事:一级路由返回展示页面,二级接口返回 JSON 数据。页面加载时用 JavaScript 调用接口拉数据,图表由前端渲染。这里的核心是“爬虫和分析只做一次,接口直接读库”,否则每次刷新页面都会触发一次爬取,非常容易被限制访问。

# web/app.py import sqlite3 import pandas as pd from flask import Flask, jsonify, render_template app = Flask(__name__) DB_PATH = "../data/house.db" def query_summary() -> dict: conn = sqlite3.connect(DB_PATH) df = pd.read_sql_query( "SELECT 区域, 单价, 面积, 朝向, 装修 FROM house WHERE 单价 IS NOT NULL", conn, ) conn.close() region_mean = ( df.groupby("区域")["单价"].mean().round(0).sort_values(ascending=False) ) return { "total": int(len(df)), "avg_price": int(df["单价"].mean()), "region": region_mean.to_dict(), } @app.route("/") def index(): return render_template("index.html") @app.route("/api/summary") def api_summary(): return jsonify(query_summary()) if __name__ == "__main__": app.run(debug=True, port=5000)

pd.read_sql_query直接执行 SQL 并把结果包装成 DataFrame,省去了手写cursor.fetchall再转字典的样板代码。to_dict()输出格式对前端友好。注意数据库路径../data/house.db取决于你启动 Flask 时所在的目录,我一般会在web/目录下运行python app.py,所以路径要相对上一级。

5.3 页面模板:用原生 JavaScript 渲染表格

分析页面的模板不需要重型前端框架,原生 fetch 就能完成数据展示。展示区域均价时直接渲染成一个表格,展示图表时用后续的 ECharts 脚本替换。这样整套系统零 npm 依赖,在任何一台有 Python 环境的电脑上都能跑起来,对毕设答辩环境非常友好。

<!-- web/templates/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>二手房数据分析系统</title> </head> <body> <h3>二手房数据概览</h3> <table border="1"> <thead> <tr><th>指标</th><th>数值</th></tr> </thead> <tbody id="summaryBody"></tbody> </table> <h3>区域均价</h3> <table border="1"> <thead> <tr><th>区域</th><th>平均单价(元/平)</th></tr> </thead> <tbody id="regionBody"></tbody> </table> <script> fetch("/api/summary") .then(res => res.json()) .then(data => { const summaryBody = document.getElementById("summaryBody"); summaryBody.innerHTML = ` <tr><td>房源总量</td><td>${data.total}</td></tr> <tr><td>平均单价</td><td>${data.avg_price}</td></tr> `; const regionBody = document.getElementById("regionBody"); regionBody.innerHTML = Object.entries(data.region) .map(([region, price]) => `<tr><td>${region}</td><td>${price}</td></tr>` ).join(""); }); </script> </body> </html>

这里用模板字符串拼innerHTML,数据量不大,性能问题可以忽略。如果你已经把图表渲染成了report.html,可以在页面里加一个 iframe 直接内嵌这个报告文件,避免前端重新实现一遍 ECharts 配置。iframes 的方案在答辩演示时最稳定,页面崩了直接切地址栏的report.html兜底。

6. 增量爬取、失败续跑和毕业设计文档的三个保命技巧

增量爬取的核心是省流量、降封禁风险。抓完第一轮后,后续只需要关注新增房源,判断逻辑是看详情页链接中的code是否已经在 SQLite 的house表里。每次抓列表页时把本页所有code一次性查出来与库比对,只解析新增部分。这里有个实用技巧:用house表里最大的rowid做增量起点并不可靠,因为链家不会按时间顺序展示房源,最稳的还是每次解析后直接查重。

失败续跑是现场答辩最容易翻车的场景。爬虫跑到第 5 页断网,程序直接退出,重启后又要从第 1 页开始跑,白白等待十几分钟。我通常会把当前页码和成功页记录到一个progress.json文件里,每次成功解析后立刻更新,下次启动时读这个文件作为起始页。这个文件很小,写在任何目录都不会有性能问题。

毕业设计文档方面,常见做法是按六章来组织:绪论讲链家二手房数据的研究背景与意义、相关技术综述;系统分析画总体流程和需求;详细设计放数据库表结构、爬虫模块设计图和可视化模块接口设计;系统实现贴爬虫关键代码与图表;系统测试列出测试用例和结果;最后总结与展望写不足和后续优化方向。源码包里放三样东西:可运行的requirements.txt依赖清单、按模块整理的 Python 源码、以及一份README.md说明运行顺序,第一步安装依赖,第二步运行爬虫,第三步生成图表,第四步启动 Flask。README 里把每条命令粘贴可执行,避免现场手动拼路径。

最后留个小技巧:清洗阶段把“暂无数据”单独统计成一张缺失值表放进论文附录,答辩时老师问“爬取的数据一定完整吗”,你直接翻到那一页说“链家部分房源缺少朝向和装修信息,我对朝向做了未知值填充,对总价和面积缺失的记录做了剔除”,这个回答比单纯讲爬虫技巧更能体现工程意识。

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

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

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

立即咨询