简介:这是一套面向计算机相关专业学生的Python数据采集与可视化实战项目,以南京二手房市场为分析对象,适用于课程设计、期末大作业及毕业设计等场景,也可作为数据分析入门者的练手案例。压缩包共157个文件,约40.02MB,包含18个py源码文件、18个csv数据集、15个html页面、11个js脚本,以及65张png图表、pptx答辩演示文稿和配套说明文档,覆盖从数据抓取、清洗到可视化呈现的完整链路。项目已通过严格调试,下载后可直接运行,代码结构完整,便于对照学习采集逻辑与图表实现方式。目前已有618人学习下载,适合需要快速搭建可运行项目、积累实战经验的学习者参考借鉴。
1. 从一份南京二手房数据说起:Python 采集与可视化到底能落地成什么
南京的二手房市场有个特点,同一个小区里,楼层、朝向、装修、挂牌时间差一点,单价能差出三四千。我最早动这个念头,是因为帮朋友看房时发现,中介给的报价单和平台上挂的价格对不上,想自己拉一批数据横向比一比。于是就有了这个基于 Python 的南京二手房数据采集及可视化分析项目——它要解决的不是"爬虫怎么写",而是"怎么把散落在列表页里的房源信息,变成一张能看出价格分布、区域冷热、户型溢价的图"。
这套东西适合两类人:一类是刚学完 Python 基础语法、想找一个完整项目练手的,从 requests 发请求到 pandas 清洗再到 pyecharts 出图,链路完整;另一类是做房产、租赁、市场研究的从业者,需要一套能改改就能跑的采集分析模板。核心词就三个:Python、数据采集、可视化分析,源码是载体,PPT 是讲清楚思路的辅助。下面我按实际做下来的顺序,把选型、采集、清洗、可视化、避坑一条条拆开讲。
2. 采集前的技术选型:requests、BeautifulSoup 与 Selenium 怎么选
2.1 为什么南京二手房列表页优先用 requests + BeautifulSoup
二手房平台的列表页,绝大多数是服务端渲染的 HTML,房源标题、总价、单价、小区名、户型、面积这些字段,直接躺在返回的 HTML 源码里。这种页面用 requests 拿 HTML、BeautifulSoup 解析 DOM,是最轻的方案:不启动浏览器,单机一秒能跑好几个请求,几百条数据几分钟就下来了。
判断标准很简单:在浏览器里右键"查看网页源代码",如果能看到房源标题的文字,就是服务端渲染,用 requests;如果源码里只有一堆 script 和空 div,数据是 JS 异步填进去的,那就得换方案。我一般会先抓一个列表页存成 html 文件,用编辑器搜一下小区名,搜得到就走 requests 路线。
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 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_list(url): # timeout 必设,否则一个卡住的连接会拖死整个循环 resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding # 中文页面编码靠它兜底 resp.raise_for_status() return resp.text def parse_list(html): soup = BeautifulSoup(html, "html.parser") items = [] for li in soup.select("ul.sellListContent li"): # 选择器按实际页面结构调整 title = li.select_one("div.title a") if not title: continue items.append({ "title": title.get_text(strip=True), "url": title.get("href"), }) return items这段代码里三个参数最关键。timeout=10是保命的,没有它,遇到慢响应线程会一直挂着;apparent_encoding用来对付 GBK 和 UTF-8 混用导致的中文乱码,比硬写resp.encoding = "utf-8"稳;raise_for_status()让 4xx、5xx 直接抛异常,方便你在循环里捕获后跳过而不是把错误页当正常页解析。选择器ul.sellListContent li只是示例,实际要对着目标页面的 class 改,改完先打印前三条验证字段有没有错位。
2.2 什么时候必须上 Selenium,代价是什么
当列表页是前端框架渲染、或者翻页靠点击而不是改 URL 参数时,requests 就拿不到数据了。这时候用 Selenium 驱动一个真实浏览器,等 JS 执行完再取 DOM。代价很直接:慢,一个页面等加载两三秒,几百条就是十几分钟;吃内存,无头模式下一个 Chrome 实例也要几百 MB。
我的做法是能不用就不用。先试接口:打开浏览器开发者工具的 Network 面板,翻一页,看有没有返回 JSON 的 XHR 请求。如果有,直接请求那个接口拿 JSON,比解析 HTML 还干净。只有接口加密、参数算不出来时,才退回 Selenium。
from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def build_driver(): opts = Options() opts.add_argument("--headless=new") # 无头模式,服务器上跑必须加 opts.add_argument("--disable-gpu") opts.add_argument("--no-sandbox") # Linux 容器里不加常报错 opts.add_argument("--window-size=1920,1080") return webdriver.Chrome(options=opts) def fetch_dynamic(url): driver = build_driver() try: driver.get(url) # 显式等待,等房源节点出现再取,别用 sleep 硬等 WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, "ul.sellListContent li")) ) return driver.page_source finally: driver.quit() # 一定要 quit,否则进程越积越多--headless=new是新版无头模式,比老的--headless更接近真实浏览器行为,反爬识别率低一些。WebDriverWait配presence_of_element_located是核心,用固定time.sleep(3)是新手最常见的翻车点——网慢的时候 3 秒不够,网快的时候白等。finally里driver.quit()不能省,我见过跑一晚上攒了几十个僵尸 Chrome 进程把内存吃满的。
2.3 采集字段与分页策略的设计
字段设计决定了后面能分析什么。我一般固定采这几列:标题、小区名、区域(鼓楼、江宁、栖霞等)、户型、面积、朝向、楼层、装修、总价、单价、挂牌时间、详情页链接。其中单价很多列表页不直接给,需要总价除以面积自己算,注意单位——总价常是"万",面积是"平米",算出来是"元/平米"还是"万元/平米"要统一。
分页策略上,南京二手房一个区域动辄几十上百页,别一次性全拉。我的习惯是先跑前 5 页验证解析逻辑,字段没错位、没有大面积空值,再放开页数。翻页优先用 URL 里的page参数递增,比模拟点击"下一页"稳得多。每页之间随机 sleep 1 到 3 秒,别用固定值,固定间隔的请求节奏太像机器。
3. 数据清洗与存储:pandas 把脏数据变成能分析的表
3.1 从原始字段里抠出可计算的数值
采集下来的原始数据基本没法直接用。"3室2厅 | 89.5平米 | 南 | 精装"这种拼接字符串,得拆成户型、面积、朝向、装修四列;"总价 320 万"要变成数字 320;"单价 35754 元/平米"要变成 35754。这一步用 pandas 的str.extract配正则最省事。
import pandas as pd import re df = pd.read_csv("nanjing_ershoufang_raw.csv") # 面积:匹配"89.5平米"里的数字 df["area"] = df["info"].str.extract(r"([\d.]+)平米").astype(float) # 户型:匹配"3室2厅" df["layout"] = df["info"].str.extract(r"(\d+室\d+厅)") # 朝向:常见值枚举,匹配不到就置空 df["orientation"] = df["info"].str.extract(r"(南北|东南|西南|东北|西北|东|南|西|北)") # 总价:去掉"万"字转数字 df["total_price"] = df["total_price_raw"].str.extract(r"([\d.]+)").astype(float) # 单价自己算,避免列表页单价口径不一致 df["unit_price"] = (df["total_price"] * 10000 / df["area"]).round(0)正则里的[\d.]+比\d+多考虑了小数面积,二手房 89.5 平这种很常见,用\d+会把 .5 丢掉。朝向用枚举而不是\w+,是因为原始串里朝向后面常跟着别的字,枚举能精确截断。单价自己算这一步很重要:不同列表页的单价有的含税有的不含,口径不统一,用总价除以面积反而一致。
3.2 缺失值、异常值和重复房源的处理
清洗完先看缺失率。面积缺失超过 10% 的批次,多半是解析正则没覆盖到某种页面结构,要回去补规则,而不是直接dropna了事。异常值主要盯两个:单价低于 5000 或高于 150000 的,大概率是单位错了或者把车位、商铺混进来了;面积小于 10 平或大于 500 平的,同理。
# 缺失率排查 print(df[["area", "total_price", "unit_price"]].isna().mean()) # 异常值过滤:南京二手房单价合理区间大致 5000~150000 df = df[(df["unit_price"] >= 5000) & (df["unit_price"] <= 150000)] df = df[(df["area"] >= 10) & (df["area"] <= 500)] # 去重:同一小区+同面积+同总价,视为重复挂牌 df = df.drop_duplicates(subset=["community", "area", "total_price"], keep="first") # 区域字段统一,去掉"南京"前缀方便分组 df["district"] = df["district"].str.replace("南京", "", regex=False).str.strip()去重的subset选小区、面积、总价三列,是因为同一套房可能被多个中介重复挂,标题不一样但房源是同一套。keep="first"保留最早抓到的那条。区域字段统一这步别小看,有的页面写"南京鼓楼",有的写"鼓楼区",不统一后面 groupby 会分成两组。
3.3 存成 CSV 还是 SQLite:按用途选
数据量在几万条以内,CSV 完全够用,pandas 读写方便,发给别人也能直接打开。但如果要做增量采集——今天抓一批、明天再抓一批合并——SQLite 更合适,能按 URL 或房源 ID 做 upsert,避免重复。
import sqlite3 conn = sqlite3.connect("nanjing_ershoufang.db") # 建表时给 url 加唯一索引,重复插入自动忽略 df.to_sql("houses", conn, if_exists="append", index=False) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_url ON houses(url)") conn.commit() conn.close()if_exists="append"是增量写入的关键,配合 url 唯一索引,重复房源不会重复入库。如果每次都是全量重跑,用if_exists="replace"更省心。CSV 和 SQLite 不是二选一,我通常两个都留:SQLite 做增量存储,导出 CSV 给分析和出图用。
4. 可视化分析:用 pyecharts 把价格分布和区域冷热画出来
4.1 南京各区域均价对比:柱状图怎么配才不误导
区域均价是最直观的一张图,但直接画平均值容易误导——江宁房源多、老小区多,均价被拉低;鼓楼学区房集中,均价被拉高。所以柱状图旁边最好配一个房源数量,让读者知道每个区的样本量。
from pyecharts import options as opts from pyecharts.charts import Bar district_stat = df.groupby("district").agg( avg_price=("unit_price", "mean"), count=("unit_price", "size") ).reset_index().sort_values("avg_price", ascending=False) bar = ( Bar() .add_xaxis(district_stat["district"].tolist()) .add_yaxis("区域均价(元/平米)", district_stat["avg_price"].round(0).tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="南京各区域二手房均价对比"), xaxis_opts=opts.AxisOpts(name="区域", axislabel_opts=opts.LabelOpts(rotate=30)), yaxis_opts=opts.AxisOpts(name="元/平米"), tooltip_opts=opts.TooltipOpts(trigger="axis"), ) ) bar.render("district_price.html")sort_values让柱子从高到低排,比按字母序直观得多。axislabel_opts=opts.LabelOpts(rotate=30)是防止区域名太长挤在一起,南京的"建邺""栖霞"还好,遇到"雨花台"这种就得转一下。tooltip_opts设成trigger="axis",鼠标划过整列都能显示数值,比默认的单点触发好用。
4.2 单价分布直方图:看出市场的价格带结构
均价只给一个数,分布才告诉你市场长什么样。南京二手房单价往往不是正态分布,而是双峰——一个峰在 2 到 3 万的老小区,一个峰在 4 到 5 万的次新房。直方图能把这个结构直接暴露出来。
from pyecharts.charts import Bar import numpy as np # 按 5000 元一档分箱 bins = np.arange(0, 155000, 5000) hist, edges = np.histogram(df["unit_price"].dropna(), bins=bins) labels = [f"{int(edges[i]/10000)}-{int(edges[i+1]/10000)}万" for i in range(len(hist))] bar = ( Bar() .add_xaxis(labels) .add_yaxis("房源数量", hist.tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="南京二手房单价分布"), xaxis_opts=opts.AxisOpts(name="单价区间", axislabel_opts=opts.LabelOpts(rotate=45)), yaxis_opts=opts.AxisOpts(name="房源数"), ) ) bar.render("price_dist.html")分箱宽度 5000 元是我试出来的平衡点:太窄(1000 元)柱子太多看不清趋势,太宽(2 万)双峰会被抹平。np.histogram返回的edges比hist多一个,所以标签循环用len(hist)。如果画出来只有一个峰,先检查是不是异常值没清干净,或者样本量太小。
4.3 户型与面积的散点图:找溢价规律
散点图适合看两个连续变量的关系。把面积放 X 轴、总价放 Y 轴,每个点是一套房,颜色按区域分,能看出哪些区的点整体偏上——同样面积总价更高,说明那个区溢价高。
from pyecharts.charts import Scatter from pyecharts.commons.utils import JsCode scatter = ( Scatter() .add_xaxis(df["area"].round(1).tolist()) .add_yaxis( "房源", df[["total_price", "district"]].values.tolist(), symbol_size=6, label_opts=opts.LabelOpts(is_show=False), ) .set_global_opts( title_opts=opts.TitleOpts(title="面积-总价散点图"), xaxis_opts=opts.AxisOpts(name="面积(平米)", type_="value"), yaxis_opts=opts.AxisOpts(name="总价(万)"), ) ) scatter.render("area_price_scatter.html")type_="value"必须显式设,否则 pyecharts 默认按类目轴处理,点会等距排开,散点图就废了。symbol_size=6是让点小一点,几千个点堆一起时太大就糊成一片。label_opts(is_show=False)关掉每个点的标签,不然页面卡到打不开。
5. 采集与分析的避坑清单:五个我踩过的坑
5.1 坑一:请求头不带 Referer,翻到第二页就被拦
现象:第一页正常返回,翻到第二页开始返回空列表或者跳验证页。原因:部分站点会校验 Referer,判断请求是不是从站内点进来的,直接构造 URL 请求缺少这个头。解决:在 HEADERS 里补上"Referer": "https://目标站/列表页路径",并且保持和实际翻页路径一致。
5.2 坑二:中文乱码,一会儿正常一会儿问号
现象:同一批数据里,有的标题正常,有的全是乱码。原因:resp.encoding没设对,requests 猜编码有时猜成 ISO-8859-1。解决:统一用resp.apparent_encoding,或者从响应头Content-Type里读 charset;存 CSV 时显式写encoding="utf-8-sig",否则 Excel 打开中文会乱。
5.3 坑三:正则贪婪匹配,把整行都吞了
现象:用.*提取字段,结果把后面所有内容都匹配进来。原因:*是贪婪的,能匹配多长就匹配多长。解决:改成非贪婪.*?,或者用更精确的字符类[\d.]+、[^|]+限定范围。我一般先用re.findall在几条样本上验证,再批量跑。
5.4 坑四:Selenium 没设无头,服务器上直接报错
现象:本地跑得好好的,放到 Linux 服务器上就崩,报DevToolsActivePort file doesn't exist。原因:服务器没有图形界面,Chrome 起不来。解决:加--headless=new、--no-sandbox、--disable-dev-shm-usage三个参数,--disable-dev-shm-usage是防止容器里 /dev/shm 太小导致崩溃。
5.5 坑五:可视化页面打开是空白
现象:render生成了 html,双击打开一片空白。原因:pyecharts 默认从 CDN 加载 echarts.min.js,离线或网络受限时加载失败。解决:改用from pyecharts.globals import CurrentConfig,把CurrentConfig.ONLINE_HOST指向本地 echarts 文件,或者用snapshot出静态图。
6. 进阶:把采集做成可复用的增量管道
跑通单次采集只是起点,真正省事的是把它做成能定期跑的增量管道。我的做法是三层:采集层按区域和页码生成任务列表,用 SQLite 的 url 唯一索引做去重;清洗层每次只处理新增记录,避免全量重算;可视化层读全库出图,保证图表始终反映最新数据。
验证管道是否正常,我有个笨办法但很管用:连续跑两天,对比数据库总行数和当天新增行数。如果第二天新增是 0,要么是去重逻辑把新数据也挡了,要么是采集层没拿到新页。这时候去看日志里每个任务的返回条数,比盯着最终结果猜快得多。
一个具体技巧是给采集任务加断点续跑。把已完成的页码记到一张progress表里,程序启动先读进度,从上次中断的页继续。这样即使跑到一半被中断,也不用从头再来。我吃过一次亏:跑了 40 分钟到第 80 页,网络抖了一下整个脚本退出,没有断点,只能重跑。从那以后我所有采集脚本第一件事就是写进度表。
import sqlite3 def get_progress(conn, district): row = conn.execute( "SELECT last_page FROM progress WHERE district=?", (district,) ).fetchone() return row[0] if row else 1 def save_progress(conn, district, page): conn.execute( "INSERT INTO progress(district, last_page) VALUES(?, ?) " "ON CONFLICT(district) DO UPDATE SET last_page=excluded.last_page", (district, page), ) conn.commit()ON CONFLICT ... DO UPDATE是 SQLite 的 upsert 写法,比先查再插少一次往返。进度表按区域存,因为不同区域页数差很多,混在一起没法续跑。
这套东西值不值得做,我的判断是:如果你只是偶尔看一次房价,手动翻翻就行;但如果你要持续跟踪某个区域、或者想拿数据做点分析,把采集和可视化搭起来,一次投入后面每次都是几分钟的事。我现在的习惯是每周跑一次增量,出图存成 html,需要的时候直接翻。希望帮到你。
本文还有配套的精品资源,点击获取