Python爬虫实战:南京二手房数据采集与可视化分析
2026/9/12 2:45:52 网站建设 项目流程

简介:这是一份基于Python的南京二手房数据采集与可视化分析完整项目,适合正在学习网络爬虫、数据分析及机器学习聚类的开发者和相关专业学生,也可作为毕业设计或课程项目的参考模板。项目以链家网南京二手房为目标,完整覆盖Requests与BeautifulSoup爬虫采集、Pandas数据清洗、Matplotlib可视化分析及k-means聚类建模,并借助高德地图JS API展示房源分布,帮助使用者系统理解从数据获取、清洗、分析到业务洞察的全流程。压缩包共158个文件,包含18个Python脚本、18份CSV数据(含原始与清洗后多版本)、65张可视化图片、15个HTML图表页面以及JS、配置文件、演示文稿等,整体约39.99MB,目录结构清晰,便于按阶段学习与复用。目前已有3231人学习下载,运行脚本即可复现南京二手房房价规律、区域分布特征与房源分类结果,为购房决策或进一步研究提供数据支撑。

1. 南京二手房数据采集与可视化:为什么建议自己写一遍爬虫脚本

假设你手上有一份南京二手房需求清单,想快速回答“总价 300 万以内能在江宁还是浦口买到合适的房子”,最原始的办法是打开链家逐页翻、手动记表格,费时且容易遗漏。标题里这个项目要解决的正是这个问题:用 Python 批量采集南京各行政区的二手房挂牌信息,清洗成结构化数据,再用可视化图表分析价格区间、区域差异和户型分布。适合的人群是正在学 Python 数据分析的学生、需要交课程设计的开发者,或者想对一个城市楼市做数据观察的普通程序员。你不需要掌握 scrapy 这类重型框架,requests 加 BeautifulSoup 就能跑通全流程。

2. 搭建Python采集环境:requests、BeautifulSoup与目标站点结构确认

2.1 采集前的环境准备:Python安装、虚拟环境与vscode配置

开始写代码之前,先把运行时环境固定下来。建议选用 Python 3.9 及以上版本,后续用到的 pandas 2.x 和 matplotlib 3.x 对低版本 Python 的支持已经不太友好。Windows 用户在安装 Python 安装包时要勾选 Add Python to PATH,否则在命令行敲 python 会提示找不到命令;macOS 用户建议直接 brew install python@3.11,避开系统自带的 Python 2 残留。

环境装好后,我一般会为这个爬虫项目单独建虚拟环境,避免把 requests、beautifulsoup4 装进全局依赖里,后面换项目时互相污染:

mkdir nanjing-house cd nanjing-house python -m venv venv # Windows 激活方式 venv\Scripts\activate # macOS / Linux 激活方式 source venv/bin/activate pip install requests beautifulsoup4 pandas matplotlib lxml

这段命令创建了名为 venv 的虚拟目录,激活后命令行前缀会变成 (venv)。requests 负责发 HTTP 请求,beautifulsoup4 负责解析 HTML,lxml 是比默认 html.parser 更宽容的解析引擎;pandas 和 matplotlib 留到清洗和画图阶段使用。使用 vscode 的话,按 Ctrl+Shift+P 打开命令面板,输入 Python: Select Interpreter,选中 venv 目录下的解释器,这样调试时不会出现解释器版本不一致导致的 ImportError。

2.2 理解二手房列表页的 HTML 骨架:字段在哪个标签里

接下来确认目标站点的页面结构。以链家南京二手房列表页 nj.lianjia.com/ershoufang 为例,每套房源是一个 class 为 clear 的 li 节点,内部包含五个关键信息字段。按 F12 打开浏览器开发者工具,选中一个房源条目,可以看到类似下面的结构:

<li class="clear"> <div class="title"><a>万科金域缇香 3室1厅 南北</a></div> <div class="houseInfo">3室1厅 | 89.76平米 | 南 北 | 精装</div> <div class="positionInfo">江宁 - 百家湖 - 双龙大道</div> <div class="totalPrice totalPrice2"><span>510</span>万</div> <div class="unitPrice">57112元/平米</div> </li>

把这段结构整理成字段关系表,后面写提取函数时会更清晰:

业务字段所在节点 class示例文本需要清洗的部分
房源标题.title万科金域缇香 3室1厅 南北去掉空格与朝向
户型与面积.houseInfo3室1厅89.76平米
所在位置.positionInfo江宁 - 百家湖 - 双龙大道拆成区域、商圈、道路
挂牌总价.totalPrice span510万去除单位,转整数
单价.unitPrice57112元/平米去除单位,转整数

抓取时优先用 select_one 锁定 class 子节点,不要直接匹配整段文本。positionInfo 里的位置可能因为板块划分不同出现两个或三个减号分段,拆字段时用 split("-") 后按结果长度判断,能少踩不少解析上的坑。

2.3 用浏览器开发者工具确认 Headers 与翻页 URL 规律

正式写第一个请求前,先手动打开列表页翻到第二页,观察浏览器地址栏的 URL 变化。链家这类网站的翻页规律通常是在路径末尾追加 /pg{n}/,第一页不带 pg 前缀也能访问。把规律确认下来,分页采集就只需要在 for 循环里拼 URL。

直接 requests.get 这段链接大概率返回 403,因为服务端会检查请求头里的 User-Agent 是否为真实浏览器。比较稳妥的办法是从自己浏览器里复制完整 UA,并补上 Accept、Accept-Language 等常见字段:

import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://nj.lianjia.com/" } resp = requests.get("https://nj.lianjia.com/ershoufang/pg2/", headers=headers, timeout=10) print(resp.status_code, resp.url)

这段请求重点是 headers 的完整程度。反爬拦截很多发生在 TCP 连接建立后、页面内容返回前,UA 缺失或明显是 python-requests 都会被直接丢弃。timeout 必须显式设置,否则目标服务器响应慢时程序会一直挂起,拖垮后续分页请求。

3. 编写南京二手房采集脚本:从第一套房源到全量分页循环

3.1 最小可运行脚本:抓取单页并把房源写入字典

新手写采集最容易犯的错误是一上来就写 200 行完整脚本,报错后连定位问题都困难。更稳的做法是先写一个只处理单页的解析函数,确认字段提取无误后,再套上分页循环。下面这段代码基于上一节确认的 HTML 结构,抓取第一页并打印前三条房源:

import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9" } def parse_page(html): soup = BeautifulSoup(html, "lxml") houses = [] for li in soup.select("li.clear"): title_tag = li.select_one(".title a") if not title_tag: continue title = title_tag.get_text(strip=True) house_info = li.select_one(".houseInfo").get_text(strip=True) position = li.select_one(".positionInfo").get_text(strip=True) total = li.select_one(".totalPrice span").get_text(strip=True) unit = li.select_one(".unitPrice").get_text(strip=True) houses.append({ "title": title, "house_info": house_info, "position": position, "total_price": total, "unit_price": unit }) return houses resp = requests.get("https://nj.lianjia.com/ershoufang/", headers=headers, timeout=10) resp.encoding = "utf-8" data = parse_page(resp.text) for item in data[:3]: print(item)

这里的几个异常处理需要特意说明。select_one 找不到节点时返回 None,如果直接调用 get_text 会抛 AttributeError,所以提取 title 后先做了空值判断。其他字段也建议按同样思路处理,因为列表页中偶尔混着平台推荐位或广告节点。resp.encoding 显式指定为 utf-8 也很重要,requests 偶尔会根据响应头把编码猜成 gbk,导致中文内容变成乱码。

3.2 分页循环的边界条件与反爬应对参数

单页函数跑通后,套上页码循环就能拿全量数据。这个环节最需要盯住的是循环终止条件和请求频率。链家列表页每页固定 30 套房,南京二手房整体挂牌量上万套,如果每秒钟请求一次,跑全量要几十分钟,既慢又容易触发风控。

我一般会在循环里加入随机等待和页数上限,先小范围验证数据质量:

import time import random all_houses = [] for page in range(1, 11): url = f"https://nj.lianjia.com/ershoufang/pg{page}/" resp = requests.get(url, headers=headers, timeout=10) if resp.status_code != 200: print(f"page {page} failed, status={resp.status_code}") continue page_houses = parse_page(resp.text) if not page_houses: print(f"page {page} is empty, stop loop") break all_houses.extend(page_houses) if len(all_houses) >= 200: break time.sleep(random.uniform(1, 3))

break 条件要同时判断当前页为空和总条数足够。很多列表页翻到最后一页时会渲染一个空列表,如果只判断 page_houses 是否为空,会一直请求超出实际页的范围;反过来只判断条数,可能因为板块筛选数据不满 200 而无穷翻页。sleep 间隔建议用 random.uniform 生成随机数,固定延迟在对方的访问日志里更容易被识别为自动化程序。

3.3 落盘与日志:CSV分阶段写、异常不中断采集

数据在内存中累积后要考虑落盘策略。常见做法是全部抓完一次性写入 CSV,但采集过程一旦中途崩溃,前面几十页的数据全部作废。更稳妥的做法是解析完一页就追加写入一页,配合文件锁保证并发安全,不过单进程场景直接用追加模式打开文件即可:

import csv from pathlib import Path output_path = Path("nanjing_house.csv") fieldnames = ["title", "house_info", "position", "total_price", "unit_price"] file_exists = output_path.exists() with open(output_path, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if not file_exists: writer.writeheader() for item in all_houses: writer.writerow(item)

这里的两个参数细节值得记住。encoding 用 utf-8-sig 而不是 utf-8,是因为 Excel 直接打开无 BOM 的 UTF-8 文件时,中文表头会变成乱码;带 BOM 后双击就能正常预览。newline="" 是为了规避 Windows 平台下 csv 模块在每行末尾多写一个回车符的问题。增量写入牺牲了少量磁盘 IO 性能,换来的好处是采集中断后只需要继续执行未完成的页码,不需要清空文件重来。

4. 数据清洗与预分析:把文本型价格泛化成数值型指标

4.1 “万”和“元/㎡”的单位换算与缺失值策略

采集到的 total_price 原始值是“510万”,unit_price 是“57112元/平米”,pandas 读取后都会被识别成 object 类型,不能直接参与排序和聚合。清洗的第一步是把单位剥掉并转成浮点数:

import pandas as pd df = pd.read_csv("nanjing_house.csv") df["total_price"] = ( df["total_price"] .astype(str) .str.replace("万", "", regex=False) .astype(float) ) df["unit_price"] = ( df["unit_price"] .str.replace("元/平米", "", regex=False) .astype(float) )

str.replace 里 regex=False 看起来很细节,但必须写上。total_price 原值没有正则特殊字符,不设这个参数也能跑,但 unit_price 中的括号、斜杠如果不关闭正则解析,替换结果会完全错乱。缺失值策略我遵循一条原则:能反推的反推,不能反推的直接删除。面积缺失不能拿平均面积填充,因为电梯房和老破小的面积结构差异极大;单价缺失时尝试用总价除以面积反推,反推结果落在正常区间才保留,否则标记后剔除。最后执行 df.dropna(subset=["total_price", "unit_price"]),保证进入分析的每一行都是完整可用的。

4.2 区域、商圈、户型的标准化处理

位置字段原始值是“江宁 - 百家湖 - 双龙大道”,用 split 拆分时要注意部分房源只有两个分段,还有小区名里带短横线的情况。稳妥的方式是拆完之后按列拼接,让空值自然落到缺省位置:

pos_split = df["position"].str.split("-", expand=True) pos_split.columns = ["district", "biz_area", "street"] df = pd.concat([df, pos_split], axis=1) df["district"] = df["district"].str.strip()

户型字段里已经包含“室”“厅”信息,用正则提取,同时构造一个粗粒度分档:

df["bedrooms"] = df["house_info"].str.extract(r"(\d)室(\d)厅")[0].astype(int) df["house_type"] = pd.cut( df["bedrooms"], bins=[0, 2, 3, 10], labels=["小户型", "三房", "改善型"] )

pd.cut 的边界要按实际样本量调整。如果采集到的数据里 4 室以上房源数量很少,直接分组会让图表上的这一类别失去统计意义;此时可以把 4 室以上合并为“改善型”,而不是强行保留多个空类别。

4.3 用SQLite做本地存储的结构与字段设计

清洗完的数据可以直接导出为新的 CSV,但如果打算每天增量采集,CSV 的读写粒度太粗,建议落到 SQLite。SQLite 是 Python 标准库自带的嵌入式数据库,不需要额外安装服务端,保存几千条二手房数据完全没有压力。建表时给 URL 字段加唯一索引,是后面做增量去重的基础:

CREATE TABLE IF NOT EXISTS house ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, district TEXT, biz_area TEXT, total_price REAL, unit_price REAL, area REAL, bedrooms INTEGER, url TEXT UNIQUE );

Python 侧用 sqlite3 写入时,pandas 的 to_sql 不会生成唯一索引,更严谨的方式是先用上面的 SQL 建表,再把 DataFrame 转成元组列表,用 executemany 批量插入,配合 INSERT OR IGNORE 跳过重复 URL:

import sqlite3 conn = sqlite3.connect("nanjing_house.db") records = list(df[["title", "district", "biz_area", "total_price", "unit_price", "area", "bedrooms", "url"]].itertuples(index=False)) conn.executemany( """ INSERT OR IGNORE INTO house (title, district, biz_area, total_price, unit_price, area, bedrooms, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, records, ) conn.commit() conn.close()

itertuples 比 iterrows 快一个数量级,在几千行数据上区别不大,但当采集扩展到上万条时,这种性能意识很重要。INSERT OR IGNORE 的语义是遇到唯一索引冲突就跳过整行,不会抛异常,正好适合增量采集场景。

5. 可视化分析:用Matplotlib绘制南京二手房价格区间分布与区域对比

5.1 直方图与频数统计:确定价格主峰区间

清洗完的数据先看整体分布。横轴单位价格长尾严重,先过滤掉单价超过 100000 元/平方米的异常挂牌,再调直方图的 bins 参数:

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False data = df[df["unit_price"] < 100000]["unit_price"] plt.figure(figsize=(10, 6)) plt.hist(data, bins=50, color="#2c7fb8", alpha=0.8, edgecolor="white") plt.xlabel("挂牌单价(元/平米)") plt.ylabel("房源数量") plt.title("南京二手房挂牌单价分布") plt.tight_layout() plt.savefig("unit_price_hist.png", dpi=150)

Matplotlib 绘制中文图时最常见的坑是方框字,原因是默认字体没有中文字形。rcParams 里指定 SimHei 后还需要把 axes.unicode_minus 设为 False,否则负号会渲染成乱码。bins 值从 50 起步,如果柱子过于细碎看不清楚就往 30 调,过于平滑就增到 80,这组参数每次分析都应该重新评估。

5.2 行政区挂牌均价对比:横向条形图绘制

南京各行政区的价格差异用横向条形图最直观。统计口径选择中位数而不是平均值,因为建邺区个别高端楼盘会把平均单价拉得很高,中位数更能代表普通房源的挂牌水平:

median_price = ( df.groupby("district")["unit_price"] .median() .sort_values() ) plt.figure(figsize=(10, 8)) median_price.plot.barh(color="#41ab5d") plt.xlabel("单价中位数(元/平米)") plt.ylabel("行政区") plt.title("南京各行政区二手房挂牌单价中位数对比") plt.tight_layout() plt.savefig("district_median.png", dpi=150)

groupby 后必须 sort_values,否则条形图会按地区名的拼音顺序排列,视觉规律完全找不到。横向条形图的坐标轴目录较长时,可以用 figsize 的宽度参数放长画布,让区域名称完整显示而不重叠。

5.3 户型与总价的关系:箱线图解读

要回答“三房比两房贵多少”这类问题,箱线图比柱状图更有信息量,它同时呈现中位数、四分位距和离群点:

import seaborn as sns sns.set_style("whitegrid") plt.figure(figsize=(10, 6)) sns.boxplot(data=df, x="bedrooms", y="total_price", palette="Blues") plt.ylim(0, df["total_price"].quantile(0.9)) plt.xlabel("户型室数") plt.ylabel("挂牌总价(万)") plt.title("不同户型挂牌总价箱线图") plt.tight_layout() plt.savefig("total_price_box.png", dpi=150)

先用 quantile(0.9) 截断 y 轴而不是直接去点,能让视觉焦点保持在主体分布上。分析结论前要打印每个卧室数分组的样本量,比如 df.groupby("bedrooms").size(),样本量少于 20 的分类不具备统计代表性,应该在结论里注明。

6. 增量更新与完整性校验:让采集脚本每天自动运行而不重复抓取

6.1 对采集结果做完整性校验:条数核对与字段空值检查

每次采集完成后,不要急着出图表,先跑一段校验逻辑。我习惯把校验脚本独立出来,检查三个容易出问题的点:总行数是否大于 0、关键价格字段是否全为正值、区域字段是否都在预定义的南京行政区集合中:

expected_districts = {"鼓楼", "玄武", "建邺", "秦淮", "江宁", "浦口", "栖霞", "雨花台", "六合", "高淳", "溧水"} assert len(df) > 0, "采集结果为空" assert (df["total_price"] > 0).all(), "存在非正总价" unknown_districts = set(df["district"].dropna()) - expected_districts assert not unknown_districts, f"发现未知区域: {unknown_districts}"

第三处断言里的未知区域需要小心处理。链家偶尔会出现“南京周边”这类跨区板块,直接 assert 会把正常采集判为失败。我的策略是先把未知区域单独打印出来人工确认,确认确实是新的行政区划标签时,把它加进 expected_districts,而不是简单地把断言删掉。

6.2 定时更新与日志残留排查

当数据库表结构里已经建好 url 唯一索引后,增量采集只需要保留之前的 INSERT OR IGNORE 写入逻辑,重复运行脚本不会产生重复数据。配合系统 cron 定时任务,每天凌晨执行一次就能持续积累挂牌变动数据:

5 2 * * * cd /home/user/nanjing-house && /home/user/nanjing-house/venv/bin/python crawler.py >> crawler.log 2>&1

cron 表达式里 2:05 是链家列表页访问量较低的时段,调低被限流概率。命令里用绝对路径指向 venv 里的 python 解释器很关键,cron 运行时的 PATH 环境和用户 shell 不同,直接写 python 很可能调用到系统全局解释器,导致 import requests 直接失败。末尾的 2>&1 把 stderr 重定向到同一个日志文件,排查凌晨运行失败时,直接 tail crawler.log 就能看到具体 traceback,而不是对着空终端猜原因。

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

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

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

立即咨询