尧图精选

链家二手房数据分析实战:从爬虫到Jupyter可视化全流程

🕒 发布时间:2026/10/1 4:25:27 📁 来源:尧图网络
简介这份源码资源面向具备Python基础、希望入门数据采集与分析的学习者围绕链家二手房市场展开完整实战。项目通过爬虫抓取房源信息再借助Jupyter Notebook完成数据清洗、聚类、可视化与报告撰写覆盖从数据获取到结论呈现的全流程适合课程设计、期末报告或数据分析练手场景。压缩包共20个文件约30.68MB包含13张PNG可视化图表、1个IPYNB笔记本、1个PY爬虫脚本、1个CSV数据表以及PPTX演示文稿、DOCX报告、TXT说明和MD文档图表涉及房价分布、区域热力图、户型占比与聚类分析等。目前已有559人学习下载。读者可据此复现爬虫逻辑、复用分析代码与图表模板并参考配套报告与演示文稿快速搭建自己的二手房数据分析项目。1. 链家二手房数据分析从爬虫到 Jupyter 的完整落地路径二手房数据是房产领域里少有的、公开程度高且结构相对规整的数据源。链家作为国内覆盖面较广的房产平台其挂牌信息包含总价、单价、户型、面积、楼层、朝向、建成年代、小区名称、区域位置等字段这些字段组合起来足以支撑一套完整的数据分析流程。很多人学完 Python 基础后卡在「不知道拿什么练手」的阶段而链家二手房数据恰好是一个理想的实战对象数据量适中、字段语义清晰、业务问题直观从爬取、清洗、存储到可视化分析每个环节都能对应到真实工作场景。这篇文章面向的是有 Python 基础但缺乏完整项目经验的开发者或者想用 Jupyter 做数据分析但不知道从哪找数据的人。我会把整个链路拆开讲清楚怎么抓、怎么存、怎么在 Jupyter 里分析、哪些参数必须调、哪些坑一定会踩。整套流程不需要复杂的分布式架构一台普通笔记本就能跑通。2. 爬虫方案选型与链家页面结构拆解2.1 为什么选 requests BeautifulSoup 而不是 Selenium链家的二手房列表页和详情页在大多数城市站点上仍然是服务端渲染的 HTML也就是说用 requests 拿到页面源码后关键字段直接藏在 HTML 标签里不需要等 JavaScript 执行完再取。这意味着 requests BeautifulSoup 的组合就够用了速度比 Selenium 快一个数量级资源占用也低得多。Selenium 的优势在于处理动态渲染和交互操作但代价是每个页面都要启动浏览器实例抓几百条数据可能就要好几分钟。链家虽然在某些城市和某些时段会触发验证码或跳转但常规列表页和详情页的静态结构在大部分情况下是稳定的。我一般会先用 requests 试抓一页确认字段能直接解析出来再决定要不要上 Selenium。选型判断的具体做法打开链家二手房列表页右键查看网页源代码不是审查元素搜索「总价」或「万」这样的关键词。如果能在源码里直接搜到说明是服务端渲染requests 可用如果搜不到但审查元素里能看到说明是前端渲染才需要考虑 Selenium 或其他方案。2.2 列表页与详情页的字段分布链家二手房的数据分布在两个层级列表页提供房源标题、总价、单价、面积、户型、楼层、朝向、小区名、区域等概要信息详情页则额外包含建成年代、梯户比例、供暖方式、挂牌时间、交易权属等更细的字段。如果只做基础分析列表页的字段已经够用。但如果想分析房龄与单价的关系、挂牌时长与降价幅度的关联就必须进详情页。这里有个取舍列表页一页 30 条翻 100 页就是 3000 条请求量可控详情页每条都要单独请求3000 条就是 3000 次请求耗时和封禁风险都大幅上升。我的建议是第一轮只抓列表页把基础分析跑通确认分析方向后再针对特定区域或价格区间抓详情页补充字段。不要一上来就全量抓详情页那是给自己找麻烦。2.3 最小可运行爬虫代码下面是一个抓取链家二手房列表页的最小示例。代码里包含了请求头设置、页面解析和字段提取的完整逻辑。import requests from bs4 import BeautifulSoup import time import random import csv # 请求头必须带 User-Agent否则链家会返回 403 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;q0.9, } def parse_list_page(url): 解析链家二手房列表页返回房源字典列表 resp requests.get(url, headersHEADERS, timeout10) if resp.status_code ! 200: print(f请求失败状态码{resp.status_code}) return [] soup BeautifulSoup(resp.text, html.parser) items soup.select(ul.sellListContent li.clear) # 列表项选择器 results [] for item in items: try: title item.select_one(div.title a).get_text(stripTrue) total_price item.select_one(div.totalPrice span).get_text(stripTrue) unit_price item.select_one(div.unitPrice span).get_text(stripTrue) house_info item.select_one(div.houseInfo).get_text(stripTrue) position item.select_one(div.positionInfo).get_text(stripTrue) follow_info item.select_one(div.followInfo).get_text(stripTrue) results.append({ title: title, total_price: total_price, unit_price: unit_price, house_info: house_info, position: position, follow_info: follow_info, }) except AttributeError: # 某些条目结构不同跳过 continue return results def crawl_pages(city_url, max_pages5): 翻页抓取每页间隔 2-4 秒 all_data [] for page in range(1, max_pages 1): url f{city_url}/pg{page}/ print(f正在抓取第 {page} 页{url}) data parse_list_page(url) all_data.extend(data) time.sleep(random.uniform(2, 4)) # 随机间隔降低被封风险 return all_data if __name__ __main__: # 以北京为例实际使用时替换为目标城市的 URL base_url https://bj.lianjia.com/ershoufang data crawl_pages(base_url, max_pages3) print(f共抓取 {len(data)} 条房源) # 写入 CSV if data: with open(lianjia_raw.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesdata[0].keys()) writer.writeheader() writer.writerows(data)这段代码的逻辑很直接构造列表页 URL用 requests 发请求BeautifulSoup 解析 HTMLCSS 选择器提取字段最后写入 CSV。几个关键参数需要说明HEADERS里的 User-Agent 必须设置否则链家直接返回 403。Accept-Language 也建议带上模拟真实浏览器行为。time.sleep(random.uniform(2, 4))是翻页间隔固定间隔容易被识别为爬虫随机间隔更安全。2 到 4 秒是我常用的范围再快就容易触发验证。timeout10是请求超时时间防止某个请求卡死导致整个脚本挂起。encodingutf-8-sig写入 CSV 时用这个编码Excel 打开不会乱码。注意链家的 CSS 类名可能会随版本更新变化如果发现某个字段抓不到先用浏览器审查元素确认最新的选择器。3. 数据清洗与 SQLAlchemy 入库3.1 原始数据的脏乱差问题爬下来的原始数据不能直接分析至少有以下几类问题需要处理第一字段混杂。house_info字段通常长这样「2室1厅 | 89.5平米 | 南 | 精装」户型、面积、朝向、装修情况全挤在一个字符串里必须拆开。position字段类似「海淀 | 中关村 | 满五唯一」区域、板块、标签混在一起。第二数值字段带单位。total_price是「520万」unit_price是「58123元/平米」需要去掉单位转成数值类型。第三缺失值和异常值。有些房源没有标注建成年代有些单价明显偏离市场价可能是录入错误或特殊房源这些在分析前要标记或剔除。第四重复数据。同一房源可能在多次翻页中出现需要按标题或房源编号去重。3.2 用 pandas 做字段拆分与类型转换清洗环节我习惯在 Jupyter 里做因为可以边写边看中间结果。下面是一段典型的清洗代码import pandas as pd import re df pd.read_csv(lianjia_raw.csv) # 拆分 house_info 字段 def split_house_info(text): 从 2室1厅 | 89.5平米 | 南 | 精装 中提取各字段 parts [p.strip() for p in text.split(|)] result {layout: None, area: None, orientation: None, decoration: None} for p in parts: if 室 in p and 厅 in p: result[layout] p elif 平米 in p: result[area] p.replace(平米, ) elif p in [南, 北, 东, 西, 南北, 东南, 西南, 东北, 西北]: result[orientation] p else: result[decoration] p return pd.Series(result) df[[layout, area, orientation, decoration]] df[house_info].apply(split_house_info) # 拆分 position 字段 def split_position(text): parts [p.strip() for p in text.split(|)] return pd.Series({ district: parts[0] if len(parts) 0 else None, block: parts[1] if len(parts) 1 else None, tags: parts[2] if len(parts) 2 else None, }) df[[district, block, tags]] df[position].apply(split_position) # 数值转换 df[total_price_num] df[total_price].str.replace(万, ).astype(float) df[unit_price_num] df[unit_price].str.extract(r(\d)).astype(float) df[area_num] pd.to_numeric(df[area], errorscoerce) # 去重 df.drop_duplicates(subset[title], keepfirst, inplaceTrue) # 剔除面积或单价为空的记录 df.dropna(subset[area_num, unit_price_num], inplaceTrue) print(f清洗后剩余 {len(df)} 条记录) print(df[[title, total_price_num, unit_price_num, area_num, district]].head())这段代码的核心思路是用apply加自定义函数处理复合字段用str.extract和str.replace处理带单位的数值最后用drop_duplicates和dropna做去重和缺失值处理。参数说明errorscoerce让 pandas 在遇到无法转换的值时设为 NaN 而不是报错这在处理脏数据时非常实用。drop_duplicates的subset参数指定按哪些列判断重复这里用标题因为同一房源标题基本一致。3.3 SQLAlchemy 入库与表结构设计清洗完的数据建议存进数据库方便后续反复查询和分析也避免每次分析都重新跑爬虫。SQLAlchemy 是 Python 里比较通用的 ORM 工具配合 SQLite 可以零配置上手后期切换到 MySQL 或 PostgreSQL 也只需要改连接字符串。from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base declarative_base() class HouseListing(Base): __tablename__ house_listings id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(200)) total_price Column(Float) # 总价万 unit_price Column(Float) # 单价元/平米 area Column(Float) # 面积平米 layout Column(String(50)) # 户型 orientation Column(String(20)) # 朝向 decoration Column(String(50)) # 装修 district Column(String(50)) # 区域 block Column(String(50)) # 板块 tags Column(String(200)) # 标签 created_at Column(DateTime, defaultdatetime.now) # 创建 SQLite 数据库连接 engine create_engine(sqlite:///lianjia.db, echoFalse) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session() # 批量插入 for _, row in df.iterrows(): listing HouseListing( titlerow[title], total_pricerow[total_price_num], unit_pricerow[unit_price_num], arearow[area_num], layoutrow.get(layout), orientationrow.get(orientation), decorationrow.get(decoration), districtrow.get(district), blockrow.get(block), tagsrow.get(tags), ) session.add(listing) session.commit() session.close() print(入库完成)表结构设计上我把清洗后的数值字段和分类字段分开存储数值字段用 Float 类型方便聚合计算分类字段用 String 方便分组。created_at字段记录入库时间方便后续做增量更新时判断数据新鲜度。提示如果数据量超过几万条逐条session.add会比较慢可以改用session.bulk_save_objects或pandas.to_sql直接批量写入。4. Jupyter 里的探索性分析与可视化4.1 Jupyter Notebook 环境准备与数据加载Jupyter 是数据分析阶段最顺手的工具因为可以分块执行、即时看到结果。如果你用的是 AnacondaJupyter 已经预装了如果用的是 miniconda需要额外安装# 安装 Jupyter 和常用数据分析库 pip install jupyter pandas matplotlib seaborn sqlalchemy # 启动 Jupyter Notebook jupyter notebook启动后浏览器会自动打开 Jupyter 界面。如果你想把 notebook 文件存在指定文件夹可以在启动前先cd到目标目录或者启动时加--notebook-dir参数jupyter notebook --notebook-dir/path/to/your/project在 notebook 里加载数据import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from sqlalchemy import create_engine # 设置中文字体否则图表里的中文会显示为方块 plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False engine create_engine(sqlite:///lianjia.db) df pd.read_sql(SELECT * FROM house_listings, engine) print(f共加载 {len(df)} 条数据) df.describe()plt.rcParams那两行是必须的不设置中文字体的话图表标题和轴标签里的中文全是方块这是新手最常踩的坑之一。4.2 区域均价对比与户型分布分析加载完数据后先从宏观层面看几个关键分布。区域均价对比能反映不同板块的价格梯度户型分布能看出市场供给结构。# 各区域均价对比 district_price df.groupby(district)[unit_price].agg([mean, count]) district_price district_price[district_price[count] 10] # 过滤样本量过少的区域 district_price district_price.sort_values(mean, ascendingFalse) fig, ax plt.subplots(figsize(12, 6)) district_price[mean].plot(kindbarh, axax, colorsteelblue) ax.set_xlabel(均价元/平米) ax.set_title(各区域二手房均价对比) plt.tight_layout() plt.show() # 户型分布 layout_counts df[layout].value_counts().head(10) fig, ax plt.subplots(figsize(10, 5)) layout_counts.plot(kindbar, axax, colorcoral) ax.set_xlabel(户型) ax.set_ylabel(房源数量) ax.set_title(户型分布 Top 10) plt.xticks(rotation45) plt.tight_layout() plt.show()这段代码做了两件事按区域分组计算均价并排序然后画水平柱状图统计户型出现频次并画柱状图。district_price[district_price[count] 10]这行是过滤样本量过少的区域避免某个区域只有两三条数据导致均价失真。从实际跑出来的结果看通常会出现几个明显规律核心城区均价显著高于郊区两室一厅和三室一厅是供给主力面积在 70 到 120 平米之间的房源占比最高。这些规律和直觉一致但用数据验证一遍心里更有底。4.3 面积与总价的散点图与相关性计算面积和总价的关系是二手房分析里最基础的维度。散点图能直观看出两者的线性关系以及有没有明显的离群点。# 面积与总价散点图 fig, ax plt.subplots(figsize(10, 6)) ax.scatter(df[area], df[total_price], alpha0.3, s10, colorteal) ax.set_xlabel(面积平米) ax.set_ylabel(总价万) ax.set_title(面积与总价关系散点图) ax.set_xlim(0, 300) # 限制坐标轴范围排除极端值干扰 ax.set_ylim(0, 2000) plt.tight_layout() plt.show() # 计算相关系数 corr df[[area, total_price, unit_price]].corr() print(corr) # 按面积分段统计均价 df[area_bin] pd.cut(df[area], bins[0, 50, 70, 90, 120, 150, 200, 500], labels[50以下, 50-70, 70-90, 90-120, 120-150, 150-200, 200以上]) area_price df.groupby(area_bin)[unit_price].mean() print(area_price)散点图里如果看到几个总价特别高但面积不大的点那可能是学区房或特殊房源分析时可以单独标记。相关系数矩阵能告诉你面积和总价的相关系数通常在 0.7 到 0.85 之间属于强正相关但不是完全线性因为单价本身也在变化。按面积分段统计均价是个很实用的分析角度通常 50 平米以下的小户型单价最高因为总价低、流动性好90 到 120 平米区间的单价相对平稳200 平米以上的大户型单价反而可能下降因为总价高、受众窄。5. 避坑与常见问题排查5.1 爬虫被封或返回 403现象脚本跑了几页后突然返回 403 状态码或者页面内容变成验证码页面。原因请求频率过高、User-Agent 被识别、IP 被临时限制。链家对爬虫有一定检测机制固定间隔的密集请求最容易触发。解决把翻页间隔调到 3 到 6 秒并加随机抖动每次请求随机切换 User-Agent如果持续被封降低单次运行页数分多次跑。我一般会把抓取任务拆成每次 5 到 10 页跑完休息几分钟再继续。5.2 CSS 选择器失效导致字段抓空现象代码跑通了但 CSV 里很多字段是空的或者parse_list_page返回空列表。原因链家前端改版CSS 类名变了。比如sellListContent可能变成sellListContentNew之类的。解决用浏览器打开目标页面审查元素确认最新的类名更新代码里的选择器。建议把选择器集中写在配置字典里改的时候只改一处。5.3 Jupyter 里中文显示为方块现象matplotlib 图表里的标题、轴标签中文全部显示为方块或乱码。原因matplotlib 默认字体不支持中文。解决在 notebook 开头设置plt.rcParams[font.sans-serif] [SimHei]。如果系统没有 SimHei 字体可以换成[Microsoft YaHei]或[PingFang SC]macOS。设置完需要重启 kernel 才生效。5.4 SQLAlchemy 入库时字段类型不匹配现象入库时报错StatementError: (builtins.TypeError) Not a boolean value或类似类型错误。原因DataFrame 里的数值列可能包含 NaN直接传给 Float 字段会报错或者字符串列里混入了非字符串类型。解决入库前用df.fillna(0)或df.dropna()处理缺失值确保传给 String 字段的值用str()转一下。更稳妥的做法是在 ORM 模型里给字段设置nullableTrue允许空值。5.5 分析结果与直觉不符现象算出来的区域均价排序和实际市场感受不一致或者某个区域的均价明显偏低。原因样本偏差。如果只抓了前几页可能恰好集中了某个价格区间的房源或者某个区域的房源数量太少统计不具代表性。解决检查每个分组的样本量样本量少于 10 的分组不要单独下结论增加抓取页数让样本覆盖更均匀对比多个时间点的数据看趋势是否一致。6. 进阶技巧用 Jupyter 做增量更新与交互式筛选数据抓一次就完事那只是练习。真正有价值的做法是定期更新数据观察价格变化趋势。这里分享一个我在实际项目中常用的增量更新思路。核心逻辑是每次爬取新数据后不直接覆盖旧数据而是按「标题 区域」作为唯一键对比新旧记录。如果同一房源的总价发生变化记录一条价格变动历史如果是新出现的房源插入新记录如果旧房源在新数据里消失了标记为「已下架」而不是直接删除。from sqlalchemy import create_engine, text import pandas as pd engine create_engine(sqlite:///lianjia.db) # 建一张价格变动历史表 with engine.connect() as conn: conn.execute(text( CREATE TABLE IF NOT EXISTS price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, old_price REAL, new_price REAL, change_date TEXT ) )) conn.commit() # 对比新旧数据 old_df pd.read_sql(SELECT title, total_price FROM house_listings, engine) new_df pd.read_csv(lianjia_raw_new.csv) # 新抓的数据 merged new_df.merge(old_df, ontitle, suffixes(_new, _old), howinner) price_changed merged[merged[total_price_new] ! merged[total_price_old]] # 记录价格变动 with engine.connect() as conn: for _, row in price_changed.iterrows(): conn.execute(text( INSERT INTO price_history (title, old_price, new_price, change_date) VALUES (:title, :old, :new, :date) ), { title: row[title], old: row[total_price_old], new: row[total_price_new], date: pd.Timestamp.now().strftime(%Y-%m-%d), }) conn.commit() print(f本次发现 {len(price_changed)} 条价格变动)这段代码的关键在于merge的howinner只保留新旧数据里都存在的房源然后筛选出价格变化的记录。price_history表独立存储变动历史不影响主表结构。在 Jupyter 里你还可以用ipywidgets做交互式筛选比如用下拉菜单选择区域、用滑块选择价格区间实时更新图表。这对做数据看板或者给非技术同事演示特别有用。from ipywidgets import interact, Dropdown interact(districtDropdown(options[全部] list(df[district].unique()))) def show_district(district): if district 全部: subset df else: subset df[df[district] district] fig, ax plt.subplots(figsize(8, 4)) ax.hist(subset[unit_price], bins30, colorsteelblue, edgecolorwhite) ax.set_xlabel(单价元/平米) ax.set_ylabel(房源数量) ax.set_title(f{district} 单价分布) plt.show()interact装饰器会自动生成下拉菜单每次选择新区域就重新执行函数并刷新图表。这个技巧在探索数据时非常高效不用反复改代码。最后说一个我自己的习惯每次跑完爬虫先把原始数据存一份 CSV 备份再入库。数据库可能因为字段调整或误操作需要重建但原始 CSV 是最后的后悔药。另外Jupyter notebook 里的分析代码不要只存在 notebook 里定期导出成.py脚本方便版本管理和复用。这些习惯看起来不起眼但能省下大量返工时间。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →