Python爬虫与可视化实战:二手房数据采集清洗全流程解析
2026/9/16 16:24:35 网站建设 项目流程

简介:一套面向毕业设计场景的Python二手房爬虫与可视化分析系统,整合网络爬虫、数据清洗与可视化技术,适合计算机专业学生、毕业设计选题者及爬虫初学者参考学习。资源包共有一百九十个文件,以Python源码、CSV数据、HTML可视化页面、PNG图表、PPTX答辩演示为主,同时包含JavaScript辅助脚本、配置文件、说明文档及字体文件,压缩包整体约四十兆字节。目前已有四十五人学习下载。数据文件覆盖原始数据、清洗后数据以及UTF-8/ANSI不同编码格式,便于对比理解数据预处理过程;代码与配套文档结构清晰,源自获评九十八分的毕业设计,经过稳定性测试,能够帮助读者完整体验从网页抓取、信息解析、数据存储到可视化展示的实现流程,并了解项目组织方式。资源来自网络分享,仅用于学习交流,请勿商用。

1. 一套 98 分的 Python 爬虫毕设,数据流才是重点

前两天帮朋友看了一套毕业设计:“基于 Python 的二手房数据爬取与可视化分析系统”。资源包打开,里面没有花哨的界面,只有 8 个 CSV 和一套完整源码,从ershoufang-origin-utf8.csv原始采集,到ershoufang-clean-utf8-v1.1.csv清洗结果,命名规范得像一条生产线报表。很多学生把“爬虫毕设”做成“单页爬虫 + 画几张图”,但这个项目拿到了 98 分,导师批注里反复出现“数据完整、复用性好”。它解决的核心问题不是怎么抓一个页面,而是二手房信息采集后如何可靠落盘、如何拆掉脏字段、如何让可视化反哺结论。适合想系统掌握 Python 网络爬虫、pandas 数据预处理和 pyecharts 数据可视化的开发者,也适合准备毕业设计答辩的计算机专业学生照着复现。

2. Requests + BeautifulSoup 采集层:翻页、反爬、落盘的三步应对

2.1 页面结构先于代码:先定位列表页与详情页字段

写爬虫前花 20 分钟看页面结构,比急着写代码划算得多。二手房列表页的 DOM 一般呈三层:外层列表容器、内层房源<li>、以及每个<li>里的标题、房屋信息和价格。用浏览器开发者工具复制选择器,确认目标字段所在的 class 或 data 属性。这个项目的爬虫把精力集中在列表页,原因很直接:列表页一个<li>就包含了标题、户型、面积、朝向、总价、单价,详情页能补的只有小区介绍、经纪人信息、挂牌时间,对后续可视化的边际价值不高。抓取范围越大,越要警惕详情页请求量带来的封禁风险,所以从列表页把高频字段拿全,是效率和稳定之间的平衡点。

选型上,requests+BeautifulSoup足够应对静态页面。相比 Scrapy,这套组合没有回调链路和 Item Pipeline,理解门槛低,适合在毕业设计阶段把“解析逻辑”讲清楚。后面想工程化,可以直接迁移到 Scrapy,解析函数几乎不变,需要补的只是中间件和 Pipeline。

2.2 请求脚本:Session、随机延时与异常重试

采集脚本第一件事不是解析,而是先处理连接和反爬。常见的做法是:Session 复用连接、User-Agent 随机切换、请求间隔加随机数。Session 能把多次请求的 TCP 连接复用,减少握手开销;固定 UA 很容易被识别成脚本,所以准备一个 UA 池随机取;延时控制在 2~5 秒,既不明显拖慢进度,也不会像定时任务一样规律性触发限流。

import csv import os import random import time import requests from bs4 import BeautifulSoup BASE_URL = "https://example.com/ershoufang/pg{page}/" # 示例站点,替换为实际列表页 UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", ] def fetch_page(session, page, retry=3): headers = { "User-Agent": random.choice(UA_POOL), "Referer": "https://example.com/ershoufang/", } url = BASE_URL.format(page=page) for attempt in range(retry): try: resp = session.get(url, headers=headers, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text except requests.RequestException as exc: print(f"第 {page} 页第 {attempt + 1} 次请求失败: {exc}") time.sleep(2 ** attempt) # 指数退避 2s, 4s, 8s return None

这里的参数对应实际问题:retry=3决定失败几次后放弃;2 ** attempt是指数退避,比固定间隔更能适应服务端限流;timeout=10防止请求卡死导致脚本挂起;resp.apparent_encoding是解决中文编码错乱的关键,requests 会从字节流推断编码,比硬编码utf-8稳。example.com是占位域名,实际使用时换成目标平台的真实列表页,同时把Referer改成对应频道页。

拿到 HTML 后,解析函数用select_one定位每个房源块:

def parse_list(html): soup = BeautifulSoup(html, "lxml") items = [] for li in soup.select("ul.houseList > li"): url_tag = li.select_one(".title a") info = li.select_one(".houseInfo") total_price = li.select_one(".totalPrice span") unit_price = li.select_one(".unitPrice") if url_tag is None or info is None: continue # 可能是广告位或置顶推荐,跳过 href = url_tag.get("href", "") house_code = href.rstrip("/").split("/")[-1].replace(".html", "") items.append({ "house_code": house_code, "title": url_tag.get_text(strip=True), "houseInfo": info.get_text("|", strip=True), "totalPrice": total_price.get_text(strip=True) if total_price else "", "unitPrice": unit_price.get_text(strip=True) if unit_price else "", }) return items

get_text("|", strip=True)会把子节点文本用竖线拼起来,方便后续用split("|")精确拆字段。这里保留原始字符串,清洗阶段再做结构化,比爬虫阶段直接拆好,因为后期可以看到字段原始形态,便于排查数据来源错误。空价格不报错,先填空字符串,交给清洗环节统一处理。

2.3 增量去重:用已采集 ID 集合避免文件越攒越乱

列表页会随时间变化,同一套房源可能出现在多个页码里;如果只是追加保存,CSV 会出现大量重复记录。常见做法是维护一个已采集 ID 集合,每次解析前检查house_code是否已存在。house_code从详情页链接提取,比用“小区名+价格”拼接更可靠,因为同一小区完全可能有同户型同价格的房源。

def load_seen_ids(path="seen_ids.txt"): if not os.path.exists(path): return set() with open(path, "r", encoding="utf-8") as f: return set(line.strip() for line in f if line.strip()) def save_new_items(items, seen_ids, csv_path): new_file = not os.path.exists(csv_path) or os.path.getsize(csv_path) == 0 fieldnames = ["house_code", "title", "houseInfo", "totalPrice", "unitPrice"] added_codes = [] with open(csv_path, "a", encoding="utf-8", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if new_file: writer.writeheader() for item in items: code = item.get("house_code", "") if code in seen_ids: continue seen_ids.add(code) added_codes.append(code) writer.writerow(item) with open("seen_ids.txt", "a", encoding="utf-8") as f: for code in added_codes: f.write(code + "\n") return len(added_codes)

这里有个容易踩的坑:DictWriter在追加模式下每次写入前都要判断文件是否为空,用os.path.getsize而不是f.tell(),否则可能重复写表头或漏写表头。seen_ids.txt既承担运行时去重,也充当断点续跑的标记文件,进程中断后重启不会重复采集。这种“落盘 + 增量”的处理,比爬虫脚本本身更能体现工程意识,评审老师往往会看这个点。

3. 由「原始 CSV」到「可分析 DataFrame」:字段拆分与编码陷阱

3.1 houseInfo 列拆出户型、面积、朝向、装修

原始采集的houseInfo字段长这样:"阳光花园 | 2室1厅 | 87.24平米 | 南 北 | 精装"。直接用pandas.read_csv读进来后,它只是一个字符串列,不处理没法做数值分析。清洗脚本用str.split("|", expand=True)把一列拆成多列,再逐列做类型转换和空值处理。

import pandas as pd df = pd.read_csv("ershoufang-origin-utf8.csv", encoding="utf-8") part = df["houseInfo"].str.split("|", expand=True) part.columns = ["community", "layout", "area_text", "orientation", "renovation"] df["community"] = part["community"].str.strip() df["layout"] = part["layout"].str.strip() df["area"] = pd.to_numeric(part["area_text"].str.replace("平米", ""), errors="coerce") df["orientation"] = part["orientation"].str.strip() df["renovation"] = part["renovation"].str.strip() df = df.dropna(subset=["area"]) print(df[["community", "layout", "area"]].head())

拆分逻辑的关键参数是expand=True,它返回 DataFrame 而不是 Series,省掉逐行 apply。pd.to_numeric(..., errors="coerce")把无法转换的字符串变为 NaN,之后用dropna(subset=["area"])统一过滤。不能直接astype(float),因为"87.24平米"带单位,必须先 replace。建议拆分前先df["houseInfo"].head(20)看完整格式,确认分隔符是中文竖线还是英文竖线;错了会拆出空列或者第一列堆满nan

layout 里还藏着“室、厅、卫”的结构,用str.extract配合正则提取:

df["bedrooms"] = df["layout"].str.extract(r"(\d+)室").astype(float) df["living_rooms"] = df["layout"].str.extract(r"(\d+)厅").astype(float)

r"(\d+)室"只捕获“室”前的一组数字,对“3室2厅1卫”能正确得到 3。这里用astype(float)而不是 int,是为了和后续中位数、分组均值计算时的 NaN 行为兼容;缺失值在上一轮 dropna 后已经清理,所以不会在读取时崩溃。

3.2 单价和总价的类型转换及一致性校验

列表页的totalPrice形如"185万"unitPrice形如"21237元/平"。它们都是字符串,做相关性分析前必须统一成数值。常规清洗分两步:去掉货币单位,再去掉千分位逗号,最后转成 float。

df["total_price"] = pd.to_numeric(df["totalPrice"].str.replace("万", "", regex=False), errors="coerce") df["unit_price"] = pd.to_numeric( df["unitPrice"].str.replace("元/平", "", regex=False).str.replace(",", ""), errors="coerce" ) df["computed_price"] = df["unit_price"] * df["area"] / 10000.0 df["price_diff"] = (df["total_price"] - df["computed_price"]).abs().round(2) print(df["price_diff"].describe())

regex=False容易被忽略,但这里替换的是固定字符串,不需要正则解析;显式关闭后速度更快,也避免“万”在正则里被当作特殊字符。computed_price表示“按单价×面积推算出的总价”,正常房源应接近挂牌总价;差值明显偏大时,大概率是数据源把单价和总价做成了不同口径,比如地下车库或车位混合挂牌。用describe()price_diff的分位数,能快速判断要不要补充清洗规则,而不是盲目相信爬下来的每一行。

3.3 中文编码与 Excel 乱码:为什么统一落成 utf-8-sig

资源包里同时出现utf-8ansi两种后缀的 CSV,说明项目在处理编码差异上踩过坑。Windows 上的 Excel 默认用系统 ANSI(GBK)打开 CSV,遇到纯 UTF-8 文件会显示成乱码。很多学生第一次跑完爬虫,直接在 Excel 打开就看到“锟斤拷”。解决方案是在to_csv时把编码指定为utf-8-sig,写入带 BOM 的文件头,Excel 就能正确识别;而 pandas 和 Python 脚本读取时仍能正常解析。

cleaned = df[["district", "community", "layout", "area", "total_price", "unit_price", "orientation", "renovation"]] cleaned.to_csv("ershoufang-clean-utf8-v1.1.csv", index=False, encoding="utf-8-sig")

index=False很关键,否则 pandas 会输出Unnamed: 0索引列,后续读入时多出一列脏数据。项目里出现 v1.1 版本号,通常意味着同一份数据做过两轮清洗;建议在文件名里同时标注编码和版本,例如clean-utf8-v1.1.csv,多人协作或答辩拷贝时不容易拿错文件。Windows 命令行工具要处理时,再用encoding="gbk"导出一个 ANSI 版本;但可视化分析统一使用 UTF-8 版本,避免跨环境乱码。

4. 可视化分析:用 pyecharts 把房价数据变成答辩能讲的图

4.1 描述性统计:先看分布,再挑图型

进入可视化之前,先做描述性统计——不是走过场,而是决定选哪张图。如果total_price严重右偏,直接画柱状图会显得全是低总价房源,此时更该看的是分箱直方图,或者对价格取对数后展示。以下代码计算价格区间分布:

bins = [0, 100, 200, 300, 500, 1000, 5000] labels = ["0-100", "100-200", "200-300", "300-500", "500-1000", "1000+"] df["price_bucket"] = pd.cut(df["total_price"], bins=bins, labels=labels) bucket_counts = df["price_bucket"].value_counts().reindex(labels) print(bucket_counts)

pd.cutbins数组必须从小到大排列,labels数量要比 bins 少一个,否则 pandas 会抛Length of bins must be equal to the length of labelsreindex(labels)保证缺失区间显示为 0,而不是被隐藏。得到分组结果后,优先用seaborn.histplot(df["total_price"], bins=50, kde=True)看整体形态,再决定是否做区域对比。答辩时评委问“为什么选这张图”,用分布结论回答比“好看”有说服力得多。

4.2 区域聚合与柱状图、散点图

区域均价是二手房可视化里的必选项。先按行政区聚合,再取总价和单价的均值。groupby("district")后面跟agg,一次可以算多个指标,省掉多次 groupby 的开销。

district_summary = df.groupby("district").agg( avg_total=("total_price", "mean"), avg_unit=("unit_price", "mean"), count=("house_code", "count") ).sort_values("avg_total", ascending=False).reset_index() print(district_summary.head(10))

这里用聚合函数而不是分别.mean()后再 merge,代码更短。sort_values("avg_total", ascending=False)让均价最高的区排在最前,符合阅读习惯。聚合结果适合画条形图,下面用 pyecharts 生成独立 HTML:

from pyecharts import options as opts from pyecharts.charts import Bar bar = ( Bar() .add_xaxis(district_summary["district"].tolist()) .add_yaxis("平均总价(万元)", district_summary["avg_total"].round(1).tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="各区二手房平均总价"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=30)), yaxis_opts=opts.AxisOpts(name="万元"), ) ) bar.render("district_avg_total.html")

pyecharts v1+ 的接口是add_xaxisadd_yaxis,和 0.5x 版的add不兼容。如果跑的是旧教程代码,会看到AttributeError: 'Bar' object has no attribute 'add_xaxis',先检查版本。rotate=30让横轴区域名称倾斜,避免“高新区”这类四字标签叠在一起。round(1)让数值保留一位小数,保留两位会显得数据噪声大。散点图用来观察“面积-总价”关系,还可以标出离群点:

from pyecharts.charts import Scatter scatter = ( Scatter() .add_xaxis(df["area"].tolist()) .add_yaxis("总价(万元)", df["total_price"].tolist(), label_opts=opts.LabelOpts(is_show=False)) .set_global_opts( title_opts=opts.TitleOpts(title="面积与总价关系"), xaxis_opts=opts.AxisOpts(name="面积(m²)", type_="value"), yaxis_opts=opts.AxisOpts(name="总价(万元)", type_="value"), datazoom_opts=[opts.DataZoomOpts(range_start=0, range_end=100)], ) ) scatter.render("area_total_scatter.html")

散点图真正有价值的部分在datazoom_opts,它让浏览者可以缩放数据区间,把右上角的高总价、高面积离群点单独放大观察。很多 Python 爬虫毕业设计只做静态图,评委会追问“异常值你怎么处理”,这个在线缩放交互可以直接演示分析过程。

4.3 交互 HTML 面板与答辩演示顺序

把多个 pyecharts 图表塞进一个 HTML 页面,用PageTab。一套演示面板建议按“区域均价 → 户型结构 → 价格分布 → 面积散点”的顺序排列,先把宏观对比抛出来,再收束到微观特征。源码里生成的是dashboard.html,包含 6 张图表,通过page.add(bar, scatter, pie, map_chart)组合。

from pyecharts.charts import Page, Pie pie = ( Pie() .add("", [list(x) for x in df["layout"].value_counts().head(8).items()]) .set_global_opts(title_opts=opts.TitleOpts(title="户型分布")) ) page = Page(layout=Page.SimplePageLayout) page.add(bar, scatter, pie) page.render("dashboard.html")

Page.SimplePageLayout是保留水平布局的简易模式,图表按网格排布,不需要手写 CSS。Pie 的add参数:第一个参数是系列名,第二个必须是[(label, value), ...]的列表,先用[list(x) for x in items]转换。value_counts().head(8)只保留出现次数最高的 8 种户型,其余不展示,避免饼图出现十几块看不清。答辩演示时,先打开 dashboard,点击散点图缩放区域,再对照区域均价柱状图解释房源分布,这个流程能明显提升“数据可视化分析”的评分观感。

5. 复现与排错:让这套系统在你的机器上跑起来

5.1 环境依赖与最小运行命令

拿到源码后,不建议直接python main.py。先建虚拟环境,避免污染系统 Python。核心依赖是requestsbeautifulsoup4lxmlpandaspyechartsmatplotlib。安装后按以下顺序复核目录:原始 CSV、清洗脚本、爬虫脚本、可视化脚本。确保ershoufang.csv这个主数据文件存在,后续分析都以它为准。

python -m venv .venv source .venv/bin/activate # Windows 下执行 .venv\Scripts\activate pip install requests beautifulsoup4 lxml pandas pyecharts python -c "import pandas; print(pandas.__version__)"

python -c是排查环境问题最快的命令,能立刻区分是“模块没装”还是“版本冲突”。如果导入 pyecharts 报ImportError: cannot import name 'options',八成是安装到了 0.5.x,升级到 1.x 即可。

5.2 数据校验与解析失效时的排错路径

爬虫跑完别直接信数字,先抽样 20 条和页面做对比。一个很实用的校验方法:用单价×面积反推总价,差值超过 5% 的行打印警告。还有更隐蔽的问题——ershoufang-origin-ansi.csv用 GBK 编码保存,在 Linux 上默认 utf-8 读取会报UnicodeDecodeError。脚本里应捕获编码异常并尝试二次读取:

try: df = pd.read_csv("ershoufang-origin-ansi.csv", encoding="utf-8") except UnicodeDecodeError: df = pd.read_csv("ershoufang-origin-ansi.csv", encoding="gbk")

解析失效时,先重新请求列表页,确认类名是否还是.houseInfo;若页面改版但爬虫没更新,采集到的houseInfo列会整列为空。此时不用急着重写解析器,先看df['houseInfo'].isna().mean()的空值率,再决定是否重抓某个页面范围。

5.3 从毕设到工程化:定时更新与增量监控

源码已经具备基础的采集清洗流程,但没有调度器。把它接到 crontab,每周一凌晨两点自动采集并重算统计数据,人工成本几乎为零。最后一行给出可复用的调度指令:

crontab -e 0 2 * * 1 cd /home/user/ershoufang && python run_pipeline.py --fetch --clean --visualize >> logs/run.log 2>&1

0 2 * * 1表示周一凌晨 2 点执行,>> logs/run.log 2>&1把标准输出和标准错误都落盘,方便事后排查定时任务为什么没跑起来。有了日志和退出码检查,这套爬虫系统就从“毕业设计源码”升级成了可持续维护的轻量数据服务。

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

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

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

立即咨询