ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python二手房数据采集与可视化分析实战:从爬虫到图表

Python二手房数据采集与可视化分析实战:从爬虫到图表 简介这是一套面向计算机相关专业学生的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;q0.9, } def fetch_list(url): # timeout 必设否则一个卡住的连接会拖死整个循环 resp requests.get(url, headersHEADERS, timeout10) 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(stripTrue), url: title.get(href), }) return items这段代码里三个参数最关键。timeout10是保命的没有它遇到慢响应线程会一直挂着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(--headlessnew) # 无头模式服务器上跑必须加 opts.add_argument(--disable-gpu) opts.add_argument(--no-sandbox) # Linux 容器里不加常报错 opts.add_argument(--window-size1920,1080) return webdriver.Chrome(optionsopts) 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否则进程越积越多--headlessnew是新版无头模式比老的--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], keepfirst) # 区域字段统一去掉南京前缀方便分组 df[district] df[district].str.replace(南京, , regexFalse).str.strip()去重的subset选小区、面积、总价三列是因为同一套房可能被多个中介重复挂标题不一样但房源是同一套。keepfirst保留最早抓到的那条。区域字段统一这步别小看有的页面写南京鼓楼有的写鼓楼区不统一后面 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_existsappend, indexFalse) conn.execute(CREATE UNIQUE INDEX IF NOT EXISTS idx_url ON houses(url)) conn.commit() conn.close()if_existsappend是增量写入的关键配合 url 唯一索引重复房源不会重复入库。如果每次都是全量重跑用if_existsreplace更省心。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, ascendingFalse) bar ( Bar() .add_xaxis(district_stat[district].tolist()) .add_yaxis(区域均价(元/平米), district_stat[avg_price].round(0).tolist()) .set_global_opts( title_optsopts.TitleOpts(title南京各区域二手房均价对比), xaxis_optsopts.AxisOpts(name区域, axislabel_optsopts.LabelOpts(rotate30)), yaxis_optsopts.AxisOpts(name元/平米), tooltip_optsopts.TooltipOpts(triggeraxis), ) ) bar.render(district_price.html)sort_values让柱子从高到低排比按字母序直观得多。axislabel_optsopts.LabelOpts(rotate30)是防止区域名太长挤在一起南京的建邺栖霞还好遇到雨花台这种就得转一下。tooltip_opts设成triggeraxis鼠标划过整列都能显示数值比默认的单点触发好用。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(), binsbins) labels [f{int(edges[i]/10000)}-{int(edges[i1]/10000)}万 for i in range(len(hist))] bar ( Bar() .add_xaxis(labels) .add_yaxis(房源数量, hist.tolist()) .set_global_opts( title_optsopts.TitleOpts(title南京二手房单价分布), xaxis_optsopts.AxisOpts(name单价区间, axislabel_optsopts.LabelOpts(rotate45)), yaxis_optsopts.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_size6, label_optsopts.LabelOpts(is_showFalse), ) .set_global_opts( title_optsopts.TitleOpts(title面积-总价散点图), xaxis_optsopts.AxisOpts(name面积(平米), type_value), yaxis_optsopts.AxisOpts(name总价(万)), ) ) scatter.render(area_price_scatter.html)type_value必须显式设否则 pyecharts 默认按类目轴处理点会等距排开散点图就废了。symbol_size6是让点小一点几千个点堆一起时太大就糊成一片。label_opts(is_showFalse)关掉每个点的标签不然页面卡到打不开。5. 采集与分析的避坑清单五个我踩过的坑5.1 坑一请求头不带 Referer翻到第二页就被拦现象第一页正常返回翻到第二页开始返回空列表或者跳验证页。原因部分站点会校验 Referer判断请求是不是从站内点进来的直接构造 URL 请求缺少这个头。解决在 HEADERS 里补上Referer: https://目标站/列表页路径并且保持和实际翻页路径一致。5.2 坑二中文乱码一会儿正常一会儿问号现象同一批数据里有的标题正常有的全是乱码。原因resp.encoding没设对requests 猜编码有时猜成 ISO-8859-1。解决统一用resp.apparent_encoding或者从响应头Content-Type里读 charset存 CSV 时显式写encodingutf-8-sig否则 Excel 打开中文会乱。5.3 坑三正则贪婪匹配把整行都吞了现象用.*提取字段结果把后面所有内容都匹配进来。原因*是贪婪的能匹配多长就匹配多长。解决改成非贪婪.*?或者用更精确的字符类[\d.]、[^|]限定范围。我一般先用re.findall在几条样本上验证再批量跑。5.4 坑四Selenium 没设无头服务器上直接报错现象本地跑得好好的放到 Linux 服务器上就崩报DevToolsActivePort file doesnt exist。原因服务器没有图形界面Chrome 起不来。解决加--headlessnew、--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_pageexcluded.last_page, (district, page), ) conn.commit()ON CONFLICT ... DO UPDATE是 SQLite 的 upsert 写法比先查再插少一次往返。进度表按区域存因为不同区域页数差很多混在一起没法续跑。这套东西值不值得做我的判断是如果你只是偶尔看一次房价手动翻翻就行但如果你要持续跟踪某个区域、或者想拿数据做点分析把采集和可视化搭起来一次投入后面每次都是几分钟的事。我现在的习惯是每周跑一次增量出图存成 html需要的时候直接翻。希望帮到你。本文还有配套的精品资源点击获取
返回列表