十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Python的二手房数据爬取与可视化分析系统实践

基于Python的二手房数据爬取与可视化分析系统实践 简介这是一份面向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 fhttps://bj.lianjia.com/ershoufang/pg{page}/ resp requests.get(url, headersHEADERS, timeout10) 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(stripTrue).split( | ) if house_info else [] position_parts position_info.get_text(stripTrue).split( | ) if position_info else [] follow_parts follow_node.get_text(stripTrue).replace( , ) if follow_node else items.append({ code: code, title: title_node.get_text(stripTrue), 小区: position_parts[0] if position_parts else , 区域: position_parts[1] if len(position_parts) 1 else , 总价: total_node.get_text(stripTrue) if total_node else 0, 单价: unit_node.get_text(stripTrue) 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好二是houseInfo和followInfo这类字段是短横线分隔的模板文本直接用 | 切分可以稳定拿到面积、朝向、楼层等子字段。如果你在 PyCharm 里单步调试会发现某些房源的positionInfo只有一个元素所以代码里对长度做了判断避免切片越界。字段缺失时给空字符串而不是跳过房源能保住样本量清洗时再统一处理缺失值。2.3 频率控制、UA 轮换与异常重试写爬虫不得不在“拿全数据”和“别给目标站造成压力”之间取平衡。常见做法是每页抓取之间随机睡眠 1 到 3 秒并把请求头中的 User-Agent 伪装成主流浏览器的版本。还可以加一个简单的重试装饰器遇到ConnectionError或 5xx 时最多重试 3 次而不是让整个程序中断。这里的核心思想是让爬虫具备断点续跑能力抓完的页码记录到本地文件里下次启动自动跳过。import logging from functools import wraps logging.basicConfig(levellogging.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_retries3) def fetch_html(url: str): resp requests.get(url, headersHEADERS, timeout10) 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配合errorscoerce让无法转换的值变成 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(万, ), errorscoerce ) df[单价] pd.to_numeric( df[单价].astype(str).str.replace(元/平, ), errorscoerce ) df[面积] pd.to_numeric( df[面积].astype(str).str.replace(平米, ), errorscoerce ) df[关注] pd.to_numeric(df[关注], errorscoerce) df df.replace(, np.nan) return dfastype(str)是防止个别原始值已经是数字时报错str.replace会作用于整列文本。注意“单价”和“总价”在这套数据里是两套口径总价是挂牌价单价是房源单价二者理论上应该满足“总价约等于面积乘单价”但有小部分房源会因地下室、车位等情况偏离分析时可以保留展示时建议过滤掉偏离超过 50% 的异常行避免散点图被极端值撑爆。3.2 去重与缺失值填充策略房源唯一编码code是天然的主键直接用它去重最可靠。列表页翻页时偶尔会出现同一套房源重复出现在不同页的情况这是正常的排序机制导致的不处理会虚增样本量。缺失值处理上朝向和装修缺失的房源可以直接填充为“未知”面积或单价缺失的行建议删除因为这两个字段是所有分析图表的基础补出来的值没有意义。楼层字段在区域均价分析中用不到可以保留原样作为备选。def dedup_and_fill(df: pd.DataFrame) - pd.DataFrame: df df.drop_duplicates(subset[code], keepfirst) df df.dropna(subset[总价, 单价, 面积]) df[朝向] df[朝向].fillna(未知) df[装修] df[装修].fillna(未知) return df去重时keepfirst意味着重复房源只保留第一次出现的那条记录后续翻页遇到同一编码直接忽略。dropna删除的是关键指标缺失的房源这套逻辑和论文里的“数据预处理”章节是直接对应的答辩时老师问为什么样本量和链家页面显示的不一致就回答“剔除缺失关键字段的记录后保证统计口径一致”。3.3 存到 SQLiteCSV 只适合中间态爬虫和清洗的结果如果只存成 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_existsappend, indexFalse) 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_state42) scatter ( Scatter() .add_xaxis(sample[面积].round(1).tolist()) .add_yaxis( 房源, sample[单价].tolist(), symbol_size6, label_optsopts.LabelOpts(is_showFalse), ) .set_global_opts( title_optsopts.TitleOpts(title二手房面积与单价分布), xaxis_optsopts.AxisOpts(name面积平米), yaxis_optsopts.AxisOpts(name单价元/平), ) ) scatter.render(output)sample做了 800 条上限的随机抽样因为浏览器渲染超过 2000 个点时交互会卡顿而散点图的分布形态在 800 个点下已经足够清晰。random_state42固定抽样种子保证论文里的图可以复现。如果你发现图中出现“面积 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, ascendingFalse) ) bar ( Bar() .add_xaxis(grouped.index.tolist()) .add_yaxis(平均单价, grouped[mean].round(0).tolist()) .set_global_opts( title_optsopts.TitleOpts(title各区域二手房平均单价), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate30)), ) ) bar.render(output)agg([mean, count])同时求出均值和样本量query(count 3)在分组之后执行过滤比先过滤再分组更简洁。rotate30是长区域名显示时的常用配置否则文字重叠。如果柱状图里出现一个你完全没听过的高价板块不用急着删数据先回数据库查一下该板块的房源是否集中在某个高端楼盘这是很好的分析结论素材。4.3 把多张图拼成一个 HTML 报告逐个渲染 HTML 的缺点是要开多个标签页演示现场答辩时很容易切乱。pyecharts 支持用Page容器把多个图放进同一个 HTML 文件并保留每个图的独立交互能力。这里推荐使用Page(layoutPage.SimplePageLayout)的纵向排列布局各图独立滚动不会互相挤压。from pyecharts.charts import Page def build_report(scatter, bar, box, output: str report.html): page Page(layoutPage.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(ascendingFalse) ) 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(debugTrue, port5000)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 langzh-CN head meta charsetUTF-8 title二手房数据分析系统/title /head body h3二手房数据概览/h3 table border1 thead trth指标/thth数值/th/tr /thead tbody idsummaryBody/tbody /table h3区域均价/h3 table border1 thead trth区域/thth平均单价元/平/th/tr /thead tbody idregionBody/tbody /table script fetch(/api/summary) .then(res res.json()) .then(data { const summaryBody document.getElementById(summaryBody); summaryBody.innerHTML trtd房源总量/tdtd${data.total}/td/tr trtd平均单价/tdtd${data.avg_price}/td/tr ; const regionBody document.getElementById(regionBody); regionBody.innerHTML Object.entries(data.region) .map(([region, price]) trtd${region}/tdtd${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 里把每条命令粘贴可执行避免现场手动拼路径。最后留个小技巧清洗阶段把“暂无数据”单独统计成一张缺失值表放进论文附录答辩时老师问“爬取的数据一定完整吗”你直接翻到那一页说“链家部分房源缺少朝向和装修信息我对朝向做了未知值填充对总价和面积缺失的记录做了剔除”这个回答比单纯讲爬虫技巧更能体现工程意识。本文还有配套的精品资源点击获取
返回列表