Python爬虫实战:南京二手房数据采集与可视化毕设全流程 简介这份资源是面向计算机相关专业学生的南京二手房数据采集与可视化分析毕业设计完整项目适合正在做大作业、课程设计或期末项目的学习者也适合需要项目实战练习的初学者。项目经导师指导并获评审99分代码完整可运行小白也能按资料自行搭建。压缩包共156个文件约39.99MB包含18个Python脚本负责数据采集与清洗、18个CSV数据文件含原始与清洗后的多版本二手房数据、65张PNG图表与2张JPG图片用于可视化结果展示、15个HTML页面与11个JavaScript文件构成前端展示界面另有TXT说明、INI配置、PPT答辩资料等辅助内容。目前已有81人学习下载。读者可获得从数据抓取、清洗到图表呈现的完整实现思路配套数据库与答辩材料便于快速理解项目结构、复现运行效果并用于毕业设计或课程设计参考。1. 南京二手房数据采集与可视化一套毕设从选题到答辩的完整落地路径南京二手房市场有个很典型的特点链家、贝壳、安居客上的挂牌数据更新频繁但真正能拿来做分析的干净数据集几乎没人免费给你。计算机毕设选题里「基于 Python 的南京二手房数据采集及可视化分析」之所以年年有人做核心原因就是它把爬虫、数据库、数据分析、可视化四条主线串成了一条完整链路工作量够、技术点全、答辩时也讲得清楚。但真正动手你会发现翻车点不在写代码而在反爬策略、字段清洗、数据库表结构设计这三处。这篇笔记按我实际做过的路径拆一遍从环境搭到采集脚本从 MySQL 建表到可视化出图再到答辩时老师最爱追问的几个点。适合正在做计算机毕设、需要一套能跑通、能讲明白、能写进论文的完整方案的人。2. 环境准备与数据源选型为什么南京二手房适合用链家做入口2.1 Python 环境与依赖库的版本选择做数据采集类毕设环境是第一道坎。我一般推荐 Python 3.8 到 3.10 之间太新的版本某些库兼容性反而会出问题。安装方式上直接用 python 官网下载安装包最稳别图省事用系统自带的。装完之后第一件事是配虚拟环境避免把全局环境搞乱。# 创建虚拟环境名字叫 nj_house python -m venv nj_house # Windows 激活 nj_house\Scripts\activate # macOS / Linux 激活 source nj_house/bin/activate # 一次性装齐核心依赖 pip install requests2.31.0 beautifulsoup44.12.2 lxml4.9.3 pandas2.0.3 pymysql1.1.0 sqlalchemy2.0.19 matplotlib3.7.2 pyecharts2.0.3这里每个库都有明确分工requests负责发请求beautifulsoup4加lxml做 HTML 解析pandas做数据清洗和统计pymysql加sqlalchemy负责入库matplotlib和pyecharts分别做静态图和交互图。版本号不是随便写的pandas 2.x和sqlalchemy 2.x搭配pymysql 1.1是目前最不容易出兼容问题的组合。如果你在 python 安装 numpy 库的方法上卡住其实 pandas 装好之后 numpy 会自动带上不用单独折腾。提示虚拟环境激活后命令行前面会出现(nj_house)前缀看到这个才说明环境生效了。2.2 数据源对比链家、贝壳、安居客哪个更适合毕设选数据源这件事直接决定你后面采集顺不顺利。我把三个主流平台的实际表现列一下平台反爬强度字段完整度分页规律适合毕设程度链家中等高含单价、朝向、楼层清晰pg参数推荐贝壳较高高需处理加密参数一般安居客高中参数混淆不推荐链家的二手房列表页 URL 结构是https://nj.lianjia.com/ershoufang/pg{页码}/分页规律非常干净翻页只需要改pg后面的数字。贝壳虽然数据也全但它的请求参数里带了加密签名对毕设来说调试成本太高。安居客的字段缺失比较严重很多房源不显示单价后期清洗会很痛苦。所以我的建议是主采集用链家如果要做数据量对比可以少量补充贝壳的数据作为交叉验证。2.3 目标字段的确定与表结构预设计在写采集代码之前先把你要抓的字段定下来这直接决定数据库表怎么建。南京二手房分析常用的字段有这些小区名称community户型layout如 3室2厅面积area单位平米朝向orientation楼层floor装修情况decoration挂牌总价total_price单位万单价unit_price单位元/平米所在区域district如鼓楼、江宁数据采集时间crawl_time字段定好之后数据库表结构基本就出来了。这里有个血泪经验total_price和unit_price一定要用DECIMAL类型而不是FLOAT否则后期做价格统计时会出现299.99999这种玄学数字答辩时被老师看到很尴尬。3. 采集脚本编写从列表页到详情页的完整抓取逻辑3.1 列表页请求与房源链接提取采集的第一步是拿到列表页里所有房源的详情页链接。链家的列表页结构比较规整每个房源卡片都在classclear LOGVIEWDATA LOGVIEW的li标签里标题链接在classtitle的a标签中。import requests from bs4 import BeautifulSoup import time import random # 请求头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 get_detail_urls(page): 抓取指定页码的列表页返回该页所有房源详情页链接 url fhttps://nj.lianjia.com/ershoufang/pg{page}/ resp requests.get(url, headersHEADERS, timeout10) if resp.status_code ! 200: print(f第 {page} 页请求失败状态码{resp.status_code}) return [] soup BeautifulSoup(resp.text, lxml) # 每个房源卡片的标题链接 links soup.select(ul.sellListContent li.clear a.title) detail_urls [link.get(href) for link in links if link.get(href)] print(f第 {page} 页共提取到 {len(detail_urls)} 条房源链接) return detail_urls if __name__ __main__: all_urls [] for pg in range(1, 6): # 先跑前 5 页测试 urls get_detail_urls(pg) all_urls.extend(urls) # 随机延时 2 到 5 秒模拟人工浏览节奏 time.sleep(random.uniform(2, 5)) print(f合计获取 {len(all_urls)} 条详情页链接)这段代码的逻辑很直白构造列表页 URL发请求用 BeautifulSoup 解析 HTML通过 CSS 选择器定位到每个房源的标题链接。time.sleep(random.uniform(2, 5))这行是关键固定间隔的请求很容易被识别为爬虫随机延时能大幅降低被封的概率。timeout10是防止某个请求卡死导致整个脚本挂起。注意链家的列表页一页默认 30 条房源如果你要采集 1000 条数据大概需要翻 34 页。别一次性跑太多建议分批跑中间间隔几分钟。3.2 详情页字段解析与数据清洗拿到详情页链接之后逐个请求并解析字段。详情页的结构比列表页复杂一些但字段位置相对固定。import re def parse_detail(url): 解析单个房源详情页返回结构化字典 resp requests.get(url, headersHEADERS, timeout10) if resp.status_code ! 200: return None soup BeautifulSoup(resp.text, lxml) data {} try: # 总价页面上的文本类似 320万 total_price_text soup.select_one(span.total).get_text(stripTrue) data[total_price] float(re.sub(r[^\d.], , total_price_text)) # 单价文本类似 35,678元/平米 unit_price_text soup.select_one(span.unitPriceValue).get_text(stripTrue) data[unit_price] float(re.sub(r[^\d.], , unit_price_text)) # 小区名称 data[community] soup.select_one(a.info).get_text(stripTrue) # 区域信息通常在 breadcrumb 里 area_info soup.select(div.areaName span.info a) data[district] area_info[0].get_text(stripTrue) if area_info else 未知 # 户型、面积、朝向等基础属性 base_info soup.select(div.base div.content ul li) for item in base_info: text item.get_text(stripTrue) if 室 in text and 厅 in text: data[layout] text elif 平米 in text: data[area] float(re.sub(r[^\d.], , text)) elif 朝向 in text or text in [东, 南, 西, 北, 南北]: data[orientation] text elif 楼层 in text or 楼 in text: data[floor] text elif 装修 in text or 精装 in text or 简装 in text: data[decoration] text except Exception as e: print(f解析失败{url}原因{e}) return None data[source_url] url return data解析逻辑的核心是「定位 清洗」两步走。定位靠 CSS 选择器清洗靠正则表达式。比如总价字段页面上显示的是320万你需要用re.sub(r[^\d.], , text)把非数字字符全部去掉再转成float。这里有个容易翻车的地方有些房源的总价是320.5万如果你的正则写成了[^\d]小数点会被吃掉变成3205数据就完全错了。所以正则里一定要保留.。3.3 反爬应对请求头、延时与异常重试链家的反爬不算特别狠但如果你不注意这几点跑不了几十条就会被封 IP。第一User-Agent必须带而且最好准备几个轮换。第二请求间隔不能太短我一般用random.uniform(2, 5)偶尔穿插random.uniform(5, 8)的长延时。第三遇到403或429状态码时不要硬刚先sleep十几秒再重试。def safe_request(url, max_retry3): 带重试机制的请求函数 for attempt in range(max_retry): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp elif resp.status_code in (403, 429): wait (attempt 1) * 10 print(f被限流等待 {wait} 秒后重试...) time.sleep(wait) else: print(f状态码 {resp.status_code}跳过) return None except requests.RequestException as e: print(f请求异常{e}第 {attempt 1} 次重试) time.sleep(5) return None这个重试函数的逻辑是正常返回就返回遇到限流就递增等待时间遇到其他异常就重试。max_retry3是经验值超过 3 次还失败的基本可以放弃了继续重试只会浪费时间。4. 数据库设计与入库MySQL 表结构与批量插入4.1 建库建表 SQL 与字段类型选择数据库这块毕设里用 MySQL 最常见也最容易讲清楚。建表的时候有几个字段类型的选择需要特别注意。CREATE DATABASE IF NOT EXISTS nj_house DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE nj_house; CREATE TABLE IF NOT EXISTS house_info ( id INT AUTO_INCREMENT PRIMARY KEY, community VARCHAR(100) NOT NULL COMMENT 小区名称, layout VARCHAR(50) COMMENT 户型, area DECIMAL(8,2) COMMENT 面积平米, orientation VARCHAR(20) COMMENT 朝向, floor VARCHAR(50) COMMENT 楼层, decoration VARCHAR(30) COMMENT 装修情况, total_price DECIMAL(10,2) COMMENT 总价万, unit_price DECIMAL(10,2) COMMENT 单价元/平米, district VARCHAR(50) COMMENT 所在区域, source_url VARCHAR(255) COMMENT 数据来源链接, crawl_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 采集时间, INDEX idx_district (district), INDEX idx_unit_price (unit_price) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT南京二手房信息表;字段类型的选择理由area用DECIMAL(8,2)是因为面积最多到 999999.99 平米两位小数够用total_price和unit_price用DECIMAL(10,2)是为了避免浮点误差district和unit_price上建索引是因为后期做区域均价统计和价格区间筛选时会频繁用到这两个字段。字符集用utf8mb4而不是utf8是因为小区名称里偶尔会出现生僻字或特殊符号utf8mb4兼容性更好。4.2 Python 批量入库脚本与去重策略入库的时候最怕的就是重复数据。同一个房源可能因为你多次运行脚本而被插入多次。解决办法是在入库前先查一下source_url是否已存在。import pymysql from sqlalchemy import create_engine import pandas as pd # 数据库连接配置 DB_CONFIG { host: localhost, port: 3306, user: root, password: your_password, database: nj_house, charset: utf8mb4, } def save_to_db(house_list): 将房源列表批量写入数据库自动跳过已存在的记录 if not house_list: print(没有数据需要入库) return conn pymysql.connect(**DB_CONFIG) cursor conn.cursor() insert_sql INSERT INTO house_info (community, layout, area, orientation, floor, decoration, total_price, unit_price, district, source_url) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) success_count 0 skip_count 0 for house in house_list: # 先检查 source_url 是否已存在 cursor.execute(SELECT id FROM house_info WHERE source_url %s, (house.get(source_url),)) if cursor.fetchone(): skip_count 1 continue try: cursor.execute(insert_sql, ( house.get(community), house.get(layout), house.get(area), house.get(orientation), house.get(floor), house.get(decoration), house.get(total_price), house.get(unit_price), house.get(district), house.get(source_url), )) success_count 1 except Exception as e: print(f插入失败{house.get(source_url)}原因{e}) conn.commit() cursor.close() conn.close() print(f入库完成成功 {success_count} 条跳过重复 {skip_count} 条)这段代码的核心逻辑是「先查后插」。虽然逐条查询效率不算最高但对毕设级别的数据量几千条来说完全够用而且逻辑清晰答辩时好讲。如果你追求效率可以把source_url设成UNIQUE索引然后用INSERT IGNORE或ON DUPLICATE KEY UPDATE但那样代码可读性会下降。提示password字段记得换成你自己的 MySQL 密码别直接复制粘贴就跑了。4.3 数据校验入库后怎么确认没丢字段数据入库之后别急着做分析先跑几条校验 SQL 确认数据质量。-- 总记录数 SELECT COUNT(*) AS total FROM house_info; -- 各区域房源数量分布 SELECT district, COUNT(*) AS cnt FROM house_info GROUP BY district ORDER BY cnt DESC; -- 检查是否有空值 SELECT COUNT(*) AS null_area FROM house_info WHERE area IS NULL; SELECT COUNT(*) AS null_price FROM house_info WHERE unit_price IS NULL; -- 单价异常值检查低于 5000 或高于 100000 的可能是脏数据 SELECT * FROM house_info WHERE unit_price 5000 OR unit_price 100000;这几条 SQL 分别检查总量、区域分布、空值和异常值。如果null_area或null_price的数量超过总数的 5%说明解析逻辑有问题需要回去检查详情页的字段定位。异常值检查也很重要南京二手房单价正常范围大概在 1 万到 8 万之间超出这个范围的要么是别墅要么是数据错误。5. 可视化分析与避坑指南出图逻辑和那些让人翻车的细节5.1 区域均价柱状图与价格分布直方图可视化是毕设答辩时最直观的部分。我一般用matplotlib做静态图放进论文用pyecharts做交互图用于答辩演示。import matplotlib.pyplot as plt import pandas as pd from sqlalchemy import create_engine # 设置中文字体否则图表上的中文会变成方块 plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False engine create_engine(mysqlpymysql://root:your_passwordlocalhost:3306/nj_house?charsetutf8mb4) # 各区域均价 df_district pd.read_sql( SELECT district, AVG(unit_price) AS avg_price, COUNT(*) AS cnt FROM house_info GROUP BY district HAVING cnt 10 ORDER BY avg_price DESC, engine ) fig, ax plt.subplots(figsize(12, 6)) bars ax.bar(df_district[district], df_district[avg_price], color#4C72B0) ax.set_xlabel(区域) ax.set_ylabel(均价元/平米) ax.set_title(南京各区域二手房均价对比) ax.tick_params(axisx, rotation45) # 在柱子上标注数值 for bar, price in zip(bars, df_district[avg_price]): ax.text(bar.get_x() bar.get_width() / 2, bar.get_height() 500, f{price:.0f}, hacenter, vabottom, fontsize9) plt.tight_layout() plt.savefig(district_avg_price.png, dpi150) plt.show()这段代码做了三件事从数据库读取各区域均价用柱状图展示在每根柱子上标注具体数值。HAVING cnt 10是为了过滤掉样本量太少的区域避免某个区域只有两三条数据导致均价失真。plt.rcParams[font.sans-serif] [SimHei]这行必须加否则中文会显示成方块这是新手最常翻的车之一。5.2 面积与总价的散点图及户型分布饼图散点图用来观察面积和总价的相关性饼图用来展示户型分布。# 面积 vs 总价散点图 df_scatter pd.read_sql( SELECT area, total_price, district FROM house_info WHERE area IS NOT NULL AND total_price IS NOT NULL AND area 500, engine ) fig, ax plt.subplots(figsize(10, 6)) scatter ax.scatter(df_scatter[area], df_scatter[total_price], cdf_scatter[total_price], cmapviridis, alpha0.6, s20) ax.set_xlabel(面积平米) ax.set_ylabel(总价万) ax.set_title(南京二手房面积与总价关系) plt.colorbar(scatter, label总价万) plt.tight_layout() plt.savefig(area_price_scatter.png, dpi150) plt.show() # 户型分布饼图 df_layout pd.read_sql( SELECT layout, COUNT(*) AS cnt FROM house_info WHERE layout IS NOT NULL GROUP BY layout ORDER BY cnt DESC LIMIT 8, engine ) fig, ax plt.subplots(figsize(8, 8)) ax.pie(df_layout[cnt], labelsdf_layout[layout], autopct%1.1f%%, startangle90, textprops{fontsize: 10}) ax.set_title(南京二手房户型分布) plt.tight_layout() plt.savefig(layout_pie.png, dpi150) plt.show()散点图里area 500这个过滤条件是为了排除极端值有些别墅面积上千平米会把整个图的比例尺拉得没法看。饼图只取前 8 个户型是因为户型种类太多全画上去标签会重叠。5.3 避坑清单采集与分析中最容易翻车的 5 个点现象一脚本跑了几页之后全部返回 403。原因请求频率太高被链家识别为爬虫。 解决把time.sleep的间隔调大到 3 到 8 秒并且每跑 5 页休息 30 秒。如果已经被封换个网络环境或者等几个小时再跑。现象二入库后发现area字段全是 NULL。原因详情页的字段定位选择器写错了或者页面结构和你写代码时不一样。 解决打开一个详情页按 F12 检查面积字段的实际 HTML 结构把选择器改对。链家偶尔会调整页面结构所以采集前先手动验证一两个页面。现象三matplotlib 图表上的中文显示成方块。原因没有设置中文字体。 解决加上plt.rcParams[font.sans-serif] [SimHei]和plt.rcParams[axes.unicode_minus] False。如果系统没有 SimHei 字体可以换成Microsoft YaHei或PingFang SC。现象四数据库里的total_price出现了299.99999这种数字。原因字段类型用了FLOAT而不是DECIMAL。 解决建表时就用DECIMAL(10,2)如果已经建好了用ALTER TABLE house_info MODIFY total_price DECIMAL(10,2);改过来。现象五重复运行脚本导致数据库里有大量重复数据。原因没有做去重逻辑。 解决入库前先按source_url查重或者给source_url加UNIQUE索引用INSERT IGNORE插入。6. 答辩加分项数据量扩充、交互式大屏与论文图表规范答辩的时候老师最常问的几个问题是「你采集了多少条数据」「数据怎么保证真实性」「你的分析和结论有什么实际意义」这三个问题其实都可以提前准备好答案。先说数据量。我建议至少采集 2000 条以上覆盖南京 8 个以上的区。如果时间充裕可以分批次采集比如今天跑 500 条明天跑 500 条这样既能避免被封又能积累足够的数据量。采集时间跨度也可以写进论文比如「数据采集时间为 2024 年 3 月至 4 月」显得更严谨。再说交互式大屏。pyecharts可以生成 HTML 文件答辩时直接用浏览器打开比静态图更有冲击力。比如用Bar做区域均价、用Pie做户型分布、用Map做区域热力图三个图拼在一个 HTML 页面里就是一个简易的数据大屏。from pyecharts.charts import Bar, Pie, Page from pyecharts import options as opts # 区域均价柱状图 bar ( Bar() .add_xaxis(df_district[district].tolist()) .add_yaxis(均价元/平米, df_district[avg_price].round(0).tolist()) .set_global_opts( title_optsopts.TitleOpts(title南京各区域二手房均价), xaxis_optsopts.AxisOpts(name区域, axislabel_optsopts.LabelOpts(rotate45)), yaxis_optsopts.AxisOpts(name均价), ) ) # 户型分布饼图 pie ( Pie() .add(, [list(z) for z in zip(df_layout[layout], df_layout[cnt])]) .set_global_opts(title_optsopts.TitleOpts(title户型分布)) .set_series_opts(label_optsopts.LabelOpts(formatter{b}: {d}%)) ) # 组合成一个页面 page Page(layoutPage.DraggablePageLayout) page.add(bar, pie) page.render(nj_house_dashboard.html)这段代码生成的nj_house_dashboard.html可以直接用浏览器打开图表支持拖拽和缩放答辩演示效果比静态图好很多。Page.DraggablePageLayout允许你在页面上自由拖动图表位置调整到满意的布局后保存即可。最后说论文图表规范。毕设论文里的图表有几个硬性要求图要有编号和图题表要有编号和表头坐标轴要有单位数据来源要标注。我一般会在论文里写「数据来源链家南京二手房频道采集时间 2024 年 3 月」这样老师一看就知道你的数据不是编的。另外论文里的图表不要直接截图粘贴尽量用代码生成的高分辨率图片dpi150或dpi300都可以打印出来清晰度够。做这个毕设最大的教训是别等到最后一周才开始跑数据。采集脚本调试、数据清洗、可视化出图每一步都可能卡住。我当时的习惯是每天跑一批数据跑完就检查入库质量有问题当天解决。这样到答辩前你手里有干净的数据、能跑的代码、好看的图表心里就不慌。希望帮到你。本文还有配套的精品资源点击获取