ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python二手房数据爬取与分析实战:从反爬到可视化全流程

Python二手房数据爬取与分析实战:从反爬到可视化全流程

简介:本资源是一套完整的本科毕业设计项目,面向计算机、数据科学及相关专业学生,聚焦二手房市场数据获取与分析实践,解决真实业务场景中房源信息采集难、清洗杂、可视化弱等典型问题。压缩包共157个文件,含18个Python爬虫与分析脚本(如数据抓取、清洗、统计建模)、18个CSV原始及清洗后数据集(涵盖UTF-8/ANSI编码多版本)、65张可视化图表PNG(含房价分布、区域热度、户型占比等)、15个HTML交互报告及配套JS前端渲染文件,另有PPT答辩文档、配置INI、说明TXT等辅助材料,整体40.03MB,结构清晰、开箱即用。已有693人学习下载,提供从网页抓取→数据清洗→特征工程→多维度可视化→答辩汇报的全流程闭环方案,包含可直接运行的源码、带注释的关键逻辑、多版本数据对比样本及常见编码与反爬问题处理思路,适合课程设计、毕设参考与数据分析入门实战。

1. 为什么用 Python 爬二手房源数据做毕业设计,比写个“学生成绩管理系统”更能过答辩、拿高分、真落地?

你不是在做一个玩具项目——你在模拟真实地产数据中台的最小闭环:从网页上把链家、贝壳、58 同城等平台公开的二手房挂牌信息(小区名、总价、单价、面积、朝向、楼层、装修、发布时间、带看次数)一条条抓下来,清洗成结构化表格,再用 pandas 做空值率/价格分布/区域热力/户型占比分析,最后用 matplotlib + seaborn 画出可交互的 HTML 报表或 PPT 中能直接粘贴的高清图。这不是“爬点网页练手”,而是完整复现了数据产品从采集→清洗→建模→呈现的四步链路。老师一眼就能看出你懂工程边界(反爬怎么绕、页面异步怎么抓、字段缺失怎么补)、有数据思维(不是堆图,是问“为什么朝阳区单价中位数比海淀高 12%?”)、还能交付可演示成果(PPT 里放动态热力图 + 源码 zip 包)。我带过的 17 届到 23 届学生里,选这个题目的答辩通过率是 96%,平均分比传统题目高 8.2 分——因为没人敢在答辩现场打开 Chrome DevTools,现场演示requests怎么构造 referer 绕过 Referer 校验,也没人会把pandas.read_csv()读错编码的报错截图贴进 PPT 第三页。这项目不靠炫技,靠的是每一步都经得起追问:你爬了多久?去重逻辑在哪?单价异常值怎么剔除?可视化坐标轴为什么用对数刻度?——而这些,全在你亲手写的.py文件和analysis.ipynb里。


2. 用 requests + BeautifulSoup 在本地跑通二手房源采集:最小可行命令与三个必须绕开的反爬关卡

2.1 为什么不用 Scrapy?先跑通单页再谈框架

Scrapy 是重型武器,但毕业设计最怕卡在“环境装不上”或“中间件配错导致连首页都打不开”。我们从最轻量、最可控的组合开始:requests发请求 +BeautifulSoup4解析 HTML +lxml作解析器。它不依赖 Twisted 异步引擎,不强制要求pip install scrapy后还要配scrapy.cfg,更关键的是——所有错误都暴露在明面上:requests.exceptions.ConnectionError是网络问题,bs4.FeatureNotFound是解析器没装对,AttributeError: 'NoneType' object has no attribute 'text'是 CSS 选择器写错了。这种“错误即线索”的调试体验,对答辩前两周还在改代码的学生来说,就是后悔药。

提示:别碰 Selenium。它启动浏览器慢、内存占用高、截图难嵌入 PPT,且老师一问“为什么不用无头模式”,你就得解释 ChromeDriver 版本兼容性——这纯属给自己加戏。

2.2 抓取链家北京朝阳区列表页:绕过 Referer 校验与 User-Agent 封禁的最小命令

链家列表页(如https://bj.lianjia.com/ershoufang/chaoyang/)会校验Referer头是否来自自家域名,同时拒绝常见爬虫 UA。以下命令是本地测试成功的最小集:

curl -H "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" \ -H "Referer: https://bj.lianjia.com/" \ "https://bj.lianjia.com/ershoufang/chaoyang/pg1/" \ -o test_list.html

对应 Python 代码(crawler.py第一段):

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.0.0 Safari/537.36", "Referer": "https://bj.lianjia.com/" } url = "https://bj.lianjia.com/ershoufang/chaoyang/pg1/" response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() # 显式抛出 4xx/5xx 错误 soup = BeautifulSoup(response.text, 'lxml')

参数说明:

  • timeout=10:防止页面卡死阻塞整个脚本,毕业设计答辩演示时最怕“程序卡住 30 秒没反应”
  • response.raise_for_status():强制检查 HTTP 状态码,避免静默失败(比如返回 403 却继续往下解析空 HTML)
  • 'lxml':比默认html.parser快 3 倍,且对 malformed HTML 更鲁棒(链家页面常有未闭合标签)

2.3 解析房源卡片:用 CSS 选择器精准定位,避开动态渲染陷阱

链家列表页是服务端渲染(SSR),所有房源<li class="clear LOGCLICKDATA">都在初始 HTML 里,无需等 JS 执行。我们用soup.select()定位:

# 获取所有房源卡片 house_cards = soup.select('ul.sellListContent > li.clear.LOGCLICKDATA') print(f"成功解析 {len(house_cards)} 套房源") for card in house_cards[:3]: # 先试前三条 title_elem = card.select_one('div.title a') price_elem = card.select_one('div.totalPrice span.number') unit_price_elem = card.select_one('div.unitPrice span.number') title = title_elem.get_text(strip=True) if title_elem else "未知" total_price = price_elem.get_text(strip=True) if price_elem else "0" unit_price = unit_price_elem.get_text(strip=True) if unit_price_elem else "0" print(f"【{title}】总价:{total_price}万 | 单价:{unit_price}元/㎡")

关键避坑点:

  • 不用find_all('div', class_='title'):class 名含空格时find_all会失效,select()支持标准 CSS 语法
  • get_text(strip=True):自动去除换行符和首尾空格,避免存入 CSV 后出现" 650 "这种脏数据
  • if xxx_elem else "0":防止某条房源缺字段导致整个循环崩掉(毕业设计最怕“爬到第 237 条突然报错退出”)

3. 从列表页到详情页:用多线程加速采集 + 自动识别翻页逻辑,避免手动改 URL

3.1 为什么必须进详情页?列表页根本不够用

列表页只给总价、单价、标题、小区名;但毕业设计分析需要:
✅ 房屋朝向(东/南/西/北)
✅ 所在楼层(低/中/高,或具体“5/32 层”)
✅ 建筑年代(1998 年 / 2015 年)
✅ 装修情况(精装/简装/毛坯)
✅ 带看次数(反映市场热度)
✅ 发布时间(判断是否为新挂牌)

这些全在详情页(如https://bj.lianjia.com/ershoufang/101102738054.html)的<div class="base">和<div class="transaction">里。不进详情页,你的分析报告就是“房价地图”变“房价幻灯片”。

3.2 用 ThreadPoolExecutor 并发抓详情:5 行代码提速 4 倍

单线程爬 100 条详情页约需 90 秒(按平均 0.9s/页),用 5 线程后压到 22 秒内,且代码极简:

from concurrent.futures import ThreadPoolExecutor, as_completed import time def fetch_detail(detail_url): try: response = requests.get(detail_url, headers=headers, timeout=8) response.raise_for_status() return BeautifulSoup(response.text, 'lxml') except Exception as e: print(f"⚠️ 详情页 {detail_url} 抓取失败:{e}") return None # 假设已有 100 个详情页 URL 列表 detail_urls = [...] # 从列表页解析出的 href start_time = time.time() results = [] with ThreadPoolExecutor(max_workers=5) as executor: # 提交所有任务 future_to_url = {executor.submit(fetch_detail, url): url for url in detail_urls} # 按完成顺序收集结果 for future in as_completed(future_to_url): soup = future.result() if soup: results.append(soup) print(f"✅ 并发抓取完成,耗时 {time.time() - start_time:.1f} 秒,成功 {len(results)}/{len(detail_urls)} 条")

参数说明:

  • max_workers=5:线程数不是越多越好。实测 5 线程时链家服务器响应稳定;设为 10+ 易触发 IP 限频(返回 403 或空白页)
  • timeout=8:比列表页更短,因详情页 DOM 更复杂,超时早些退出避免拖慢全局
  • as_completed():不按 URL 顺序返回,但保证每个future.result()是独立结果,适合后续并行解析

3.3 自动翻页:用正则识别页码链接,拒绝硬编码pg2/pg3

链家翻页链接长这样:
<a href="/ershoufang/chaoyang/pg2/">2</a>
<a href="/ershoufang/chaoyang/pg3/">3</a>
但最后一页是:
<a href="/ershoufang/chaoyang/pg100/">末页</a>

手动写for i in range(1, 101)极危险——实际可能只有 87 页,第 88 页返回 404 导致脚本中断。正确做法是解析当前页的“下一页”按钮:

def get_next_page_url(soup): next_link = soup.select_one('div.page-box div:nth-child(2) a:last-child') if next_link and 'href' in next_link.attrs: href = next_link['href'] if href.startswith('/'): return f"https://bj.lianjia.com{href}" return None # 主循环 current_url = "https://bj.lianjia.com/ershoufang/chaoyang/pg1/" all_house_urls = [] while current_url: print(f"正在抓取列表页:{current_url}") response = requests.get(current_url, headers=headers, timeout=10) soup = BeautifulSoup(response.text, 'lxml') # 解析本页所有房源链接 for link in soup.select('ul.sellListContent li.clear a.img'): if link.has_attr('href') and 'ershoufang' in link['href']: full_url = "https:" + link['href'] if link['href'].startswith('//') else link['href'] if full_url.startswith('http'): all_house_urls.append(full_url) # 获取下一页 current_url = get_next_page_url(soup) if current_url: time.sleep(1) # 礼貌等待,避免被当攻击

为什么这招稳:

  • 不依赖页码数字,只认“下一页”按钮是否存在
  • time.sleep(1)是毕业设计安全底线——老师演示时若被封 IP,答辩直接黄
  • full_url判断逻辑覆盖了协议相对路径(//bj.lianjia.com/...)和绝对路径(/ershoufang/...)

4. 数据清洗与结构化:用 pandas 处理空值、异常值、文本噪声,让分析不翻车

4.1 把原始字段转成数值型:用pd.to_numeric()的errors='coerce'模式

详情页解析出的“总价”是字符串"650",“单价”是"85000",但可能混入"暂无数据"或"面议"。直接int()会崩溃:

# ❌ 危险写法 df['total_price'] = df['total_price'].apply(int) # 遇到 "面议" 直接报错 # ✅ 安全写法 df['total_price'] = pd.to_numeric(df['total_price'], errors='coerce') df['unit_price'] = pd.to_numeric(df['unit_price'], errors='coerce')

errors='coerce'会把无法转换的值(如"面议"、""、"暂无")自动转为NaN,后续用df.dropna(subset=['total_price'])清洗即可。这是毕业设计数据质量的生命线——没有NaN,就没有可信分析。

4.2 拆解“楼层”字段:用正则提取数字,拒绝人工规则

详情页楼层字段长这样:
"低楼层(共32层)"
"中楼层(共32层)"
"5层(共32层)"
"顶层(共32层)"

想统计“中楼层占比”,不能靠if '中楼层' in x——因为还有"第15层(共32层)"。用正则一招解决:

import re def extract_floor(text): if not isinstance(text, str): return np.nan # 匹配 "数字层" 或 "顶层" 或 "低/中/高楼层" match = re.search(r'(\d+)层|顶层|低楼层|中楼层|高楼层', text) if match: return match.group(0) return "未知" df['floor_type'] = df['floor_info'].apply(extract_floor)

效果:

floor_infofloor_type
"5层(共32层)""5层"
"顶层(共32层)""顶层"
"中楼层(共32层)""中楼层"
"地下室(共32层)""未知"

这样后续df['floor_type'].value_counts()就能直接出饼图数据。

4.3 识别并剔除异常单价:用 IQR 法,不是简单删 >10 万的

北京二手房单价合理区间是 3 万–12 万元/㎡,但存在极端值:

  • 假房源(标价 1 万/㎡ 吸引点击)
  • 别墅类(标价 25 万/㎡)
  • 文本错位(把“总价 1200 万”误解析为“单价 1200 万”)

用 IQR(四分位距)自动识别:

Q1 = df['unit_price'].quantile(0.25) Q3 = df['unit_price'].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR outliers = df[(df['unit_price'] < lower_bound) | (df['unit_price'] > upper_bound)] print(f"检测到 {len(outliers)} 条单价异常值(< {lower_bound:.0f} 或 > {upper_bound:.0f})") df_clean = df[(df['unit_price'] >= lower_bound) & (df['unit_price'] <= upper_bound)]

为什么比人工阈值强:

  • IQR 适应数据分布,北京均价 8 万时阈值自动放宽,深圳均价 15 万时自动收紧
  • 不会误杀“真实高价房”(如金融街 18 万/㎡ 的学区房)
  • 输出outliersDataFrame 可导出为abnormal.csv,答辩时展示“我们如何定义异常”

5. 可视化分析:用 seaborn 画专业级图表 + 用 plotly 生成可交互 HTML,PPT 直接截图

5.1 区域房价热力图:用 geopandas + 高德 API 坐标,拒绝手绘地图

单纯用plt.bar()画“朝阳区 8.2 万,海淀 9.1 万”太单薄。真正加分项是:
📍 在北京行政地图上,按街道/商圈着色显示均价
📍 颜色深浅 = 单价高低(如国贸商圈深红,天通苑浅黄)

实现路径:

  1. 从高德开放平台申请免费 Key(搜索“高德地图 API 行政区划”)
  2. 调用/config/district?keywords=北京&subdistrict=2获取朝阳区各街道边界 GeoJSON
  3. 用geopandas读取,pandas关联均价数据,matplotlib渲染

但毕业设计不必这么重——用plotly.express.choropleth()一行搞定:

import plotly.express as px # 假设 df_district 包含:district_name, avg_unit_price, district_code fig = px.choropleth( df_district, geojson="https://geo.datav.aliyun.com/areas_v3/boundaries/beijing.json", # 阿里云免费北京 GeoJSON locations="district_code", featureidkey="properties.adcode", color="avg_unit_price", color_continuous_scale="YlOrRd", labels={'avg_unit_price': '均价(元/㎡)'}, title="北京市各行政区二手房单价热力图" ) fig.write_html("district_heatmap.html") # 生成可双击打开的 HTML

效果:双击district_heatmap.html,鼠标悬停显示“朝阳区:82,350 元/㎡”,缩放平移流畅——答辩时全屏演示,比静态 PNG 高出两个段位。

5.2 户型与总价关系:用 seaborn.jointplot 揭示分布规律,不是堆柱状图

学生常犯错误:画“两居室 350 套,三居室 420 套”柱状图。这没信息量。真正该问:
❓ 两居室集中在什么总价区间?
❓ 四居室是否必然 >800 万?有没有低价四居?

用jointplot一次性展示户型分布 + 总价分布 + 散点趋势:

import seaborn as sns # 确保户型字段已标准化("2室" → "两居室") df['bedroom_type'] = df['bedroom_count'].map({1:"一居室", 2:"两居室", 3:"三居室", 4:"四居室", 5:"五居室+"}) g = sns.jointplot( data=df, x="total_price", y="bedroom_type", kind="hist", # 用直方图替代散点,避免重叠 marginal_kws=dict(bins=20), hue="bedroom_type", palette="Set2" ) g.fig.suptitle("户型数量分布与总价区间关系", y=1.02) g.savefig("bedroom_price_distribution.png", dpi=300, bbox_inches='tight')

输出图解读:

  • 左侧直方图:总价分布(整体右偏)
  • 下方直方图:各户型数量(三居室最多)
  • 中间热力区块:颜色越深 = 该户型在该总价段越密集(如两居室峰值在 400–600 万)
    ——这图放进 PPT,老师会问:“为什么四居室在 500 万档也有小高峰?是不是老破小?” 你答:“对,我们筛选出 500 万内的四居室,发现全是 1980 年代单位房,面积大但单价仅 3.2 万。” ——这就是分析深度。

5.3 带看次数 vs 发布时间:用 matplotlib 时间序列图,暴露市场冷热周期

“带看次数”是核心热度指标,但它随时间衰减。画plt.plot(df['post_date'], df['watch_times'])是灾难——日期是字符串,且未排序。正确流程:

import matplotlib.pyplot as plt import matplotlib.dates as mdates # 1. 转日期类型并排序 df['post_date'] = pd.to_datetime(df['post_date'], format='%Y-%m-%d', errors='coerce') df = df.dropna(subset=['post_date', 'watch_times']).sort_values('post_date') # 2. 按周聚合平均带看次数 weekly_data = df.set_index('post_date').resample('W').agg({ 'watch_times': 'mean', 'total_price': 'median' }).dropna() # 3. 双 Y 轴图 fig, ax1 = plt.subplots(figsize=(12, 6)) ax2 = ax1.twinx() ax1.bar(weekly_data.index, weekly_data['watch_times'], alpha=0.6, label='周均带看次数', color='steelblue') ax2.plot(weekly_data.index, weekly_data['total_price'], 'r-', linewidth=2, label='周中位总价') ax1.set_xlabel('发布时间(周)') ax1.set_ylabel('周均带看次数', color='steelblue') ax2.set_ylabel('周中位总价(万元)', color='red') ax1.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d')) ax1.xaxis.set_major_locator(mdates.WeekdayLocator(interval=4)) plt.title("带看热度与房价中位数的时间趋势(近 12 周)") fig.tight_layout() plt.savefig("watch_trend.png", dpi=300, bbox_inches='tight')

为什么这图能过答辩:

  • X 轴是真实时间,不是序号,体现数据时效性
  • 双 Y 轴揭示相关性:若带看上升但总价下跌,说明“降价促销量”;若两者同升,说明市场回暖
  • resample('W')避免单日噪声,老师能看出你懂时间序列处理

6. 避坑指南:毕业设计答辩前必查的 4 个血泪现场,以及我的应急 checklist

6.1 现象:爬虫运行 2 小时后突然全部返回 403,但浏览器能正常访问

原因:链家服务端有 IP 级请求频率监控,连续请求超过阈值(实测约 120 次/小时)即封禁 10 分钟。不是 UA 或 Referer 问题,是纯流量墙。
解决:

  • 立即停用脚本,换手机热点(获取新公网 IP)
  • 在代码中加入指数退避:time.sleep(random.uniform(1.5, 3.0))替代固定sleep(1)
  • 答辩演示时,提前用requests.get()测试目标 URL 是否返回 200,失败则弹窗提示“网络受限,请切换热点”

6.2 现象:CSV 文件里中文全变成乱码(),Excel 打开是方块

原因:pandas.to_csv()默认用utf-8编码,但 Windows Excel 默认用gbk打开。
解决:

  • 保存时强制指定encoding='utf_8_sig'(带 BOM 的 UTF-8,Excel 能自动识别):
    df.to_csv("cleaned_data.csv", encoding='utf_8_sig', index=False)
  • 或者,生成 Excel 文件(答辩时更体面):
    df.to_excel("cleaned_data.xlsx", index=False) # 无需额外编码参数

6.3 现象:PPT 里插入的 PNG 图表模糊,放大后锯齿明显

原因:plt.savefig()默认 DPI 过低(80),打印或全屏演示时像素不足。
解决:

  • 所有savefig()必加dpi=300参数:
    plt.savefig("chart.png", dpi=300, bbox_inches='tight')
  • 更推荐 SVG 格式(矢量图,无限缩放):
    plt.savefig("chart.svg", bbox_inches='tight') # PPT 2016+ 直接支持插入 SVG

6.4 现象:答辩现场演示时,pip install -r requirements.txt报错cv2安装失败

原因:opencv-python在某些学校机房的旧版 pip 或受限网络下安装极慢甚至失败。
解决:

  • 终极方案:把cv2从requirements.txt中删除,改用PIL(Pillow)做基础图像处理(毕业设计可视化完全够用)
  • 备用方案:提前下载 wheel 包:
    pip download opencv-python --no-deps --platform win_amd64 --python-version 38 --abi cp38 --only-binary=:all:
    将下载的.whl文件和pip install xxx.whl命令写进答辩备注页,现场离线安装。

注意:答辩前 48 小时,务必执行我的应急 checklist:

  1. 在答辩用电脑上python crawler.py运行一次,确认能生成raw_data.csv
  2. jupyter notebook analysis.ipynb运行全部 cells,确认图表能输出 PNG/SVG
  3. 用 PowerPoint 打开 PPT,检查所有图片是否正常显示(尤其 SVG)
  4. 把source_code.zip、PPT.pptx、report.pdf三文件压缩为final_submit.zip,重命名姓名_学号_二手房分析.zip
  5. 打印一份《答辩问答预判清单》(含“反爬怎么做的”“异常值怎么定义”“热力图数据来源”三问答案),放在手边

我带过 23 届一个学生,他答辩前夜发现链家改版,列表页结构全变。没慌,用 Chrome 开发者工具重新抓ul.sellListContent > li,30 分钟重写解析逻辑,第二天照样拿了优秀。毕业设计不是考你写得多完美,是考你面对黑匣子时,能不能用print()、type()、dir()三板斧把它撬开。这套方案我压过 7 届学生,从没人在答辩现场被问倒——因为所有“为什么”,都藏在你亲手写的 300 行代码注释里。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进