简介:这份基于Python的杭州二手房数据采集与可视化分析设计源码,面向数据采集入门者、房产研究爱好者以及需要完成课程设计的学生。项目完整覆盖链家二手房数据的爬虫采集、清洗转换与可视化展示三大模块,包含URL管理、页面解析、数据下载、日志记录等爬虫工具,以及CSV/XLSX编码转换、去重清洗脚本和词云图、柱状图等分析图表,可帮助读者快速搭建一套二手房数据分析流程。压缩包共48个文件,以13个Python源码文件为主,辅以CSV、XLSX数据文件、TXT说明、XML配置、PNG图表等,整体大小约24.83MB,目录按数据爬虫、清洗、可视化分区,结构清晰便于学习。目前已有1055人学习下载,适合希望结合实战案例理解数据采集与可视化方法的中级学习者。
1. 杭州二手房数据采集到可视化:一套源码要解决的核心问题
想判断杭州某个板块的二手房挂牌价,如果还靠手动刷几十页房源列表,很可能被重复房源和模糊单价误导。基于Python的杭州二手房数据采集及可视化分析源码,本质上是一条“抓取公开页面 → 清洗成结构化字段 → 用图表还原行情”的流水线。采集不是它的终点,分析才是。这个方向适合三波人:准备买房想观察价格区间的用户、想练习爬虫和数据分析的开发者、需要给门店做周报的工具维护者。你不需要先懂大型框架,把列表解析、数据库设计、图表生成这几个环节串起来,就能跑出一份属于杭州的二手房价格报告。关键是别一上来就追求大而全,先用最朴素的方案跑通一条数据。
2. 先立框架:源码的模块划分与数据库设计
2.1 一个可持续运行的采集项目该分几个模块
这类项目最常见的翻车点,是把所有逻辑塞进一个脚本里,运行一次就弃坑。遇到断网、页面改版、重复数据,整个工程就废了。我倾向于按数据流向拆成五个模块:配置、采集、清洗、存储、可视化。配置模块单独一个文件,专门管URL前缀、请求间隔、字段白名单;采集模块只负责请求与解析,不管数据怎么落库;清洗模块处理单位、缺失值、异常值;存储模块封装SQLite的读写;可视化模块读取数据库生成图表。这样任何一步坏了,只需要替换对应的函数。
模块划分的价值在于可测试。拿掉网络依赖后,采集模块的解析函数可以喂一段HTML片段直接跑,清洗函数可以直接喂一组脏字典。你甚至能抽出几张本地样例页,把采集测试变成离线测试。这个习惯会让后续调试成本下降一大截,尤其是项目间隔几周再拾起来时,你不需要重新回忆每个字段是从哪一层解析出来的。模块边界清晰,回归测试才有意义。
拆模块时还有一个容易被忽视的点:字段名定义要全局统一,最好在配置里写一个字段字典。列表页有“单价”,详情页可能叫“挂牌均价”,清洗后统一为unit_price。如果你在采集层直接给字段命名,清洗层和可视化层就会跟着乱。源码里所有对外暴露的DataFrame和SQL字段,都应该来自同一个常量定义,这是维护长期项目的基本功。
考虑到二手房数据会不断更新,模块之间建议只通过数据库交互,不通过中间文件交互。采集模块把原始内容写入一张raw表,清洗模块从raw表读JSON字段解析后写入clean表。这样即使清洗逻辑写错了,原始数据还在,你还有后悔药可以吃。反过来,如果采集时就把数据结构拍扁,清洗发现遗漏字段就得重新全网抓一遍,代价很高。
2.2 字段建模与表结构设计
做杭州二手房分析,最关键的字段不是房源标题,而是“小区”“板块”“行政区”“挂牌价”“面积”“单价”“朝向”“楼层”“建筑年代”“挂牌时间”。其中“板块”和“行政区”是分析维度,一定不能漏。很多项目只看房子本身,不做地理分组,后面做区域价格对比时会非常痛苦。如果你准备做月度趋势,还要额外记录“采集日期”,否则无法追踪同一套房源的价格变化。
为了兼顾后续重跑,我一般把表拆成三张:raw_house(原始网页内容)、house_info(清洗后的结构化数据)、house_dedup(去重后的每日快照)。raw表只放url、fetch_time、html_text;house_info放可查询字段;house_dedup记录以(house_id、fetch_date)为主键的每日状态,这样便于追踪同一套房源价格变动。三张表的分工,本质上是把“原始数据、干净数据、分析数据”分层存放。
表结构可以这样定义:
CREATE TABLE raw_house ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE NOT NULL, fetch_time TEXT NOT NULL, html_text TEXT ); CREATE TABLE house_info ( house_id TEXT PRIMARY KEY, url TEXT, title TEXT, community TEXT, district TEXT, area REAL, unit_price INTEGER, total_price INTEGER, rooms TEXT, toward TEXT, floor TEXT, decoration TEXT, built_year INTEGER, listed_at TEXT, fetch_time TEXT, UNIQUE(house_id, fetch_time) ); CREATE TABLE house_dedup ( house_id TEXT, fetch_date TEXT, total_price INTEGER, unit_price INTEGER, PRIMARY KEY(house_id, fetch_date) );SQLite的字段类型在这里我刻意写得比较宽松,比如house_id直接设成TEXT,因为杭州各个平台的房源编号不一定是纯数字。area用REAL方便算均价,unit_price存整数(元/㎡),total_price存整数(万元)。把价格和面积单独拉出来做成快照表,是为了后面看挂牌价波动时不至于被历史脏数据干扰。house_info表里加了一个UNIQUE(house_id, fetch_time),防止同一批次重复写入同一套房源。
2.3 为什么选择SQLite作为起步存储
很多教程一上来就推MySQL或MongoDB,但对这种单机采集+分析的项目,SQLite几乎是性价比最高的选择。第一,零部署,Python自带的sqlite3模块直接能建库,不用装服务;第二,单文件备份方便,跑完一个月的数据,拷贝一个.db文件就能迁移;第三,后续接可视化时,直接读SQLite比接MySQL省掉一堆连接配置。
当然SQLite也有边界。如果你需要多个进程同时高频写入,或者要支撑几十个人同时查询,那它就不太合适。但对一个每天抓几千条的杭州二手房项目,SQLite的瓶颈远没有触到。我见过有人因为怕SQLite“不够专业”,硬上MySQL,结果反而卡在服务配置上,数据还没抓一条,先花了一个晚上装数据库。工具的取舍要跟着数据量走,而不是跟着“听起来专业”走。
需要特别提醒的是,SQLite写入时建议开启事务。采集脚本如果每抓一条都commit一次,磁盘IO会明显拖慢速率。更稳的做法是每一批采集完成后统一commit,遇到报错再回滚到上一批。你可以在源码里把commit执行放在finally块里,保证异常退出时数据不丢得太多。对于频繁读写,可以顺手打开WAL模式,让读写并发时更顺滑。
import sqlite3 conn = sqlite3.connect("data/hangzhou_house.db") conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA synchronous=NORMAL;")两个PRAGMA配置可以显著减少写入阻塞。journal_mode=WAL允许读操作和写操作并发执行,synchronous=NORMAL在断电时可能丢失最近的提交,但对采集场景来说,可接受。
2.4 配置文件与项目目录布局
项目目录我习惯这样铺:
hangzhou_house_project/ ├── config.py ├── db/ ├── crawler/ │ ├── list_parser.py │ ├── detail_parser.py │ └── fetcher.py ├── etl/ │ ├── cleaner.py │ └── dedup.py ├── analysis/ │ ├── charts.py │ └── report.py ├── data/ └── main.py配置文件的要点是把爬虫的“可变项”全部收敛到一处。我一般放这些内容:平台网站根URL、列表页URL模板、请求头User-Agent池、请求间隔秒数、超时时间、数据库文件路径、要保留的字段白名单。这样以后换平台或换数据库,只需要改config.py,不需要改业务代码。
# config.py BASE_URL = "https://house.example.com" LIST_URL_TEMPLATE = "https://house.example.com/district=hangzhou&page={page}" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://house.example.com/" } REQUEST_INTERVAL = 1.5 # 单位秒,防止请求过快导致IP被限制 TIMEOUT = 10 # 单个请求超时秒数 DB_PATH = "data/hangzhou_house.db" RAW_FIELDS = ["url", "title", "community", "district", "area", "room_info", "toward", "floor", "decoration", "built_year", "total_price", "unit_price", "listed_at"]配置里REQUEST_INTERVAL和TIMEOUT是调参的重中之重。间隔太短容易被限制,太长影响效率,我常用的经验值是1.2到2秒之间。TIMEOUT设在10秒,遇到坏链接迅速跳过,而不是让线程卡死。如果你后面加了代理池,代理切换策略也要写进配置,而不是散落在采集代码里。字段白名单RAW_FIELDS的作用是控制入库范围,既能节省存储,也能避免不小心把无关字段带进分析。
3. 数据采集:把杭州二手房列表页和详情页变成结构化数据
3.1 列表页请求与解析:Requests + BeautifulSoup
打开杭州二手房列表页,房源卡片通常会放在一个带统一class的容器里。用Requests把页面抓下来,再用BeautifulSoup定位每条房源的URL和基础信息。常见做法是:先取页面总页数,再按页码循环。但总页数有时候会被平台刻意隐藏,更稳妥的判断方式是“这一页没有房源就停止”。
import requests from bs4 import BeautifulSoup from config import LIST_URL_TEMPLATE, HEADERS, REQUEST_INTERVAL, TIMEOUT import time def fetch_page(page): url = LIST_URL_TEMPLATE.format(page=page) resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT) if resp.status_code != 200: raise RuntimeError(f"列表页请求失败,HTTP {resp.status_code}") return resp.text def parse_list(html): soup = BeautifulSoup(html, "html.parser") items = [] for card in soup.select("a[href*='house-id']"): house_id = card["href"].split("/")[-1] items.append({ "url": card["href"], "house_id": house_id, "title": card.get_text(strip=True) }) return items def crawl_list(max_pages=100): all_items = [] for page in range(1, max_pages + 1): html = fetch_page(page) items = parse_list(html) if not items: break all_items.extend(items) time.sleep(REQUEST_INTERVAL) return all_items第一个函数负责网络请求,第二个函数只做解析,职责分离后你很容易对parse_list做单元测试。注意这里的选择器a[href*='house-id']是我给的通用写法,实际项目里要改成目标平台的特征值。你可以先用浏览器的开发者工具找到房源链接的公共特征,再回填到这个表达式里。抓取后必须做一次set操作去重,因为有些房源会同时出现在“默认排序”和“最近上新”里。
解析列表页时,我一般只保留URL、house_id和标题。其他信息都留给详情页,因为列表页上的价格字段经常被截断,比如只显示“650”没有单位,直接拿去分析会产生误导。列表页的价值是快速拿到所有房源入口,以及控制分页终止条件。
3.2 详情页字段补齐与并发采集策略
列表页只给你房源URL和标题,真正对分析有用的面积、价格、朝向、建筑年代需要进详情页拿。如果只用单线程一个页面一个页面抓,一千套房可能要十几分钟,还容易碰到超时。我一般会用concurrent.futures的线程池来并发抓详情,但把线程数控制在4到6个。并发能提升吞吐,但也放大了被反爬的概率,所以必须与限速配合使用。
import concurrent.futures as cf def fetch_detail(house_id, url): html = fetch_page(url) data = parse_detail(html) data["house_id"] = house_id data["url"] = url return data def batch_fetch_detail(urls, max_workers=5): results = [] with cf.ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {executor.submit(fetch_detail, hid, url): hid for hid, url in urls} for future in cf.as_completed(future_map): try: results.append(future.result()) except Exception as exc: print(f"抓取失败: {future_map[future]}, {exc}") return results线程数不是越高越好。太高的并发会让你的出口IP在短时间内涌出大量请求,很早就暴露。对单机小规模采集,5个线程已经能在一小时左右拿下全杭州的几千套房源详情,再往下压时间就可能触发平台风控。as_completed的目的是让慢请求不会阻塞后面的任务,某个房源详情页卡到超时,最多丢一条数据,不会拖垮整个批次。
详情页解析函数parse_detail需要做字段兜底。比如面积字段可能显示“89.5㎡”,也可能显示“89.5平”,还有可能直接缺失。我一般用一组try/except来分别解析,拿不到就返回None,而不是抛异常中断整个线程。每个字段的缺失情况会在清洗阶段统一处理,采集阶段只负责尽量多地捞到原始文本。
3.3 反爬应对:请求头、代理与限速的常见配置
二手房平台的反爬策略重点不在验证码,而在请求头校验和频率统计。最常遇到的是“请求头缺失Referer”被拒绝,或者“同IP短时间访问次数过多”被临时限制。应对方案遵循一个原则:让自己看起来像普通用户。普通用户不会用同一个User-Agent连续刷几百页,也不会用完全固定的间隔请求。
from fake_useragent import UserAgent import random def get_headers(referer_url=None): ua = UserAgent() headers = { "User-Agent": ua.random, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", } if referer_url: headers["Referer"] = referer_url return headers def random_delay(base=1.5): # 在1.0到2.5倍之间随机浮动,避免固定频率 time.sleep(base * random.uniform(0.8, 1.6))每次请求都换一个随机User-Agent,同时带上真实Referer,能躲过大多数初级的UA校验。注意“限速”不是等比例延迟,而是加入随机扰动。固定间隔2秒的表征反而容易被识别成脚本,我一般让间隔在1.2到2.6秒之间浮动。如果你真的要部署到长期运行,建议把代理池写进去,但不要在一开始就追求这个,先把单IP跑通。
当遇到403时,不要立刻重试,而是做一个退避:第一次等10秒,第二次等30秒,第三次等60秒。重试次数超过3次就放弃这一页,记录到日志里。日志是反爬洞里最重要的一环,没有日志你根本分不清是被限制还是数据真的抓完了。我习惯在每个请求后打一条简短的访问状态,命中错误时再打详情。
3.4 断点续采与增量更新
采集过程中最怕的不是慢,而是跑到一半程序崩了,之前抓到的数据全部白费。解决方案是设计断点续采:每抓完一个列表页,就把当前页码写进一个status表;重启后从status表继续,而不是从第1页重新开始。断点续采本质上是给采集流程加了检查点,让程序可以恢复进度。
def save_progress(page): conn.execute( "INSERT OR REPLACE INTO crawl_status(page, update_time) VALUES(?, ?)", (page, now()) ) conn.commit() def load_progress(): row = conn.execute( "SELECT page FROM crawl_status ORDER BY update_time DESC LIMIT 1" ).fetchone() return row[0] if row else 1增量更新做顺了之后,你还能升级成“每日巡检”:用当天日期生成一个快照表,对同一house_id比对total_price,有变化就记录价格变动。这就是二手房价格趋势分析的雏形。不要一开始就迷信分布式爬虫,对杭州二手房这种中小体量,单机+断点续采+限速已经是性价比最高的组合,先跑通全流程,再谈扩展。
增量更新的另一个关键点是“只抓新增房源”。如果平台支持按挂牌时间排序,你把首次采集时间记为base_time,后续只抓挂牌时间晚于base_time的房源,可以省掉大量重复请求。对已经抓过的house_id,直接在SQL层面跳过,不再重复请求详情页。这样长期运行的成本会稳定在低位。
4. 数据清洗与可视化分析:从脏数据到可读图表
4.1 清洗“万”与“平米”等单位字段
这是最容易出脏数据的地方。平台展示的价格通常是“650万”“单价5.2万元/㎡”。如果不处理,总面积和总价会被当作文本存下来。清洗脚本要负责把中文单位转成数值。你可能会觉得这个过程简单,但真实数据里往往混着“万”“亿”“万元/㎡”“元/㎡”四种写法,一个不覆盖就会留下脏值。
import re def parse_total_price(raw): if raw is None: return None if isinstance(raw, (int, float)): return round(float(raw), 2) text = str(raw).strip() if "亿" in text: return round(float(text.replace("亿", "")) * 10000, 2) if "万" in text: return round(float(text.replace("万", "")), 2) return None def parse_unit_price(raw): if raw is None: return None m = re.search(r"([\d.]+)万", str(raw)) if m: return round(float(m.group(1)) * 10000, 2) m2 = re.search(r"([\d.]+)", str(raw)) if m2: return round(float(m2.group(1)), 2) return Noneparse_total_price把“万”“亿”统一换算成“万元”。parse_unit_price先处理“5.2万/㎡”这类带万的数据,再处理纯数字“58000元/㎡”。这里有个坑:有的平台会把单价标注成“58000元/㎡”,有的标注“5.8万/㎡”,清洗逻辑必须同时兼容。建议写完后用样例数据做表驱动测试,把各种格式都喂一遍,避免在真实运行里才发现格式漏匹配。
清洗价格的同时,还要清洗面积。面积字段常见的写法有“89.5㎡”“89.5平”“89.5平方米”。我习惯把单位全部剥掉,只保留浮点数。类似地,楼层字段里的“低楼层/中楼层/高楼层”在可视化时最好映射成“低/中/高”,不然排序和统计都会乱。这些看起来是小问题,但会直接影响柱状图的分组结果。
4.2 归一化处理与房价计算口径
清洗完单位和价格,下一步是归一化字段。行政区分组不统一是二手房数据里常见的问题,同一个小区在不同页面里一会儿叫“城西板块”,一会儿叫“城西”,直接分组就会变成两个组。我的做法是维护一个板块映射表,同时把文本中的空格、全角字符统一转成半角。这样图表上展示的分组名是稳定的。
def normalize_district(raw): mapping = { "城西板块": "城西板块", "城西": "城西板块", "城东板块": "城东板块", "城东": "城东板块", "城南板块": "城南板块", "城南": "城南板块", "城北板块": "城北板块", "城北": "城北板块", } raw = str(raw).strip().replace(" ", "").replace(" ", "") return mapping.get(raw, raw)房价计算口径也需要提前定义:是看挂牌价,还是看成交价?挂牌价通常比成交价高5%到10%,如果做可视化时混用两种口径,图表结论会被带偏。我一般只用挂牌价做“挂牌市场分析”,并在图表标题里明确写清“挂牌均价”,避免读者误解成真实成交价。如果后续想深入,可以再采集房源的状态标签,比如“已成交”“降价”等,单独分析。
楼层和朝向的归一化同样重要。朝向字段常见写法有“南”“南北”“南向”“朝南”,统计户型占比时要先归并。我用一套简单的规则:只要包含“南”就归为“南向”,包含“南北”归为“南北通透”,其他归为“其他”。规则不要写得太复杂,因为大多数平台给出的朝向值就那么几种,先覆盖多数,剩下的归到“其他”里即可。
4.3 可视化分析:Pyecharts / ECharts 的报表结构
可视化部分不需要写复杂的后端,常见做法是让脚本直接读取SQLite,再用Pyecharts生成图表,最后输出一个HTML文件。使用Pyecharts的好处是它把ECharts的JavaScript封装成了Python语法,生成的是静态HTML,能直接用浏览器打开,不需要启动服务。你可以在没有外网的环境下运行,因为图表库文件会随HTML一起嵌入。
from pyecharts.charts import Bar from pyecharts import options as opts def chart_district_avg_price(df): df = df.groupby("district")["unit_price"].mean().sort_values(ascending=False) bar = ( Bar() .add_xaxis(df.index.tolist()) .add_yaxis("平均挂牌单价(元/㎡)", df.round().tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="杭州各板块二手房挂牌均价"), yaxis_opts=opts.AxisOpts(name="元/㎡"), xaxis_opts=opts.AxisOpts( name="板块", axislabel_opts={"rotate": 30} ) ) ) return bargroupby一句就能得到各板块的平均单价。图表参数里最有经验的技巧是“坐标轴旋转”:当板块名超过4个时,不旋转会重叠,旋转30度是折中值。如果你还想加更多图表,建议把每个图表函数都返回一个独立的Bar或Line对象,最后统一渲染到一个页面布局里,别把生成逻辑和渲染逻辑混在一起。这样后续换渲染方式或者调整布局,只需要动一个文件。
饼图适合展示户型占比,散点图适合展示面积与总价的关系。散点图是最能反映“有没有异常数据”的图表,如果清洗不干净,散点图上会出现大量远离主集群的点。我把它当成质量校验工具来用,每次跑完数据先看散点图,再决定要不要继续做后续分析。
4.4 生成图表的具体参数设置
生成整套报告时,我一般把多个图表按顺序写进一个Page,最后统一渲染。这里有两个关键参数:页面标题和每个图表的宽度。Pyecharts支持Page(layout=Page.SimplePageLayout)来纵向堆叠图表,阅读体验最接近一篇分析报告。如果图表太多,可以给每个图表加height参数,让页面滚动时不会过于拥挤。
from pyecharts.charts import Bar, Pie, Scatter, Page import sqlite3 import pandas as pd conn = sqlite3.connect("data/hangzhou_house.db") df = pd.read_sql( "SELECT district, total_price, unit_price, area, rooms FROM house_dedup", conn ) page = Page(layout=Page.SimplePageLayout) page.add(chart_district_avg_price(df)) page.add(chart_room_type_pie(df)) page.add(chart_area_price_scatter(df)) page.render("report.html") print("报告已生成:report.html")整个报表流程可以直接从数据库读数据,也就是说采集和可视化解耦。数据库表里的house_dedup是每日快照,包含当天所有房源的价格和面积。如果你想多看几个月的趋势,可以再写一段按周聚合的Line图,但不要在第一次跑通时贪多。先保证柱状图、饼图、散点图这三类能生成,再逐步扩展。图表配色保持默认就好,不需要花里胡哨,分析报告的核心是数字准确、分组清晰、坐标轴有单位。
5. 避坑指南:杭州二手房采集到报表的6个常见问题
5.1 现象:页面结构和字段突然变
采集跑了两周后,某天列表页解析返回空列表,或者字段变成None。原因是对接的页面模板改了class名称,或平台加了新的反爬元素。解决:把采集逻辑里的选择器统一放到配置或单独的selector.py文件里,页面改版时只改一处;同时给parse_list加一个最低解析数量断言,比如“解析结果小于50条就报警”,而不是静默通过。报警可以用简单的print写入日志,也可以接一个Webhook,但后者不是初版必须。
我自己的习惯是每三天做一次抽样比对:手动打开目标平台,搜索一个已知板块,看看列表页的HTML结构是否和当前选择器匹配。不要等到图表数据出现断崖才发现页面改版,那样你至少损失了一轮采集周期。页面改版是这类项目最常见的“带刺更新”,只能靠监控来兜底。
5.2 现象:数据量不少,但图表出现异常值
清洗完数据后,柱状图里某板块的均价冲到每平方米15万元,明显不符合真实行情。原因:同一个板块被错误归并,或者房源里混杂了车位、商铺等非住宅。解决:在清洗层加过滤条件,把面积小于10平方米或大于2000平方米的样本剔除;同时抽查异常高的价格,确认是不是别墅或类独栋房源。必要时用分位数(如保留5%到95%区间)再画图。
散点图对这类问题最敏感。面积10平方米以下、总价300万的样本大概率不是住宅,而是车位或商业公寓。你在采集时可能没有单独过滤房源类型,分析时就应该按面积和单价组合条件排除。过滤条件同样要写在清洗模块,不要放在可视化脚本里,否则每次换一批数据都要重新过滤。
5.3 现象:采集过程中IP被限制
抓了大概几百页之后,请求返回403或需验证的页面。原因:单IP请求频率超过平台阈值。解决:先降低线程数,把间隔拉大到2到3秒;再给请求加随机延迟,避免固定间隔。如果你必须长期采集,建议准备多个IP来源,但不要在一开始就依赖代理池,先确认单一IP下能把限速调到什么程度。有的平台是“小时计数”,过了限制时间后会自动恢复,所以不需要频繁更换IP。
我在实际调试中发现,很多时候被限制不是总请求量太高,而是某个瞬间的并发峰值太高。线程池的max_workers=5看起来不大,但如果5个详情页同时请求,间隔又没控制好,瞬时QPS还是会冲到3以上。解决方法是把限速放到请求函数内部,而不是在线程池外部统一sleep。这样每个线程自己等自己的,整体请求分布更均匀。
5.4 现象:挂牌价和成交价差异
可视化报告里房源均价明显高于某中介平台的统计口径,读者质疑数据不准。原因:挂牌价本身包含业主预期溢价,且平台挂牌价并不等于真实成交价。解决:第一,所有图表标题和说明里明确标注是“挂牌均价”;第二,如果精力允许,可以追踪某些房源的“降价”标签,分析调价趋势,作为对挂牌价的一个修正参考。二手房市场里“挂牌价”和“成交价”的分道扬镳是常态,不是bug。
如果你要写分析结论,建议区分“挂牌均价”和“业主调价幅度”。前者反映供给端预期,后者反映业主心态的变化。比如某板块挂牌价没怎么动,但“降价房源占比”在升高,说明真实成交可能已经在松动。这种结论才是数据采集项目真正的价值所在。
5.5 现象:重复房源导致主键冲突
多轮采集后,同一个house_id被重复写入,导致SQLite报UNIQUE约束错误。原因:采集时没有处理列表页的重复房源,或者同一房源在“必看好房”和“普通列表”里各出现一次。解决:在列表解析后立即用house_id做去重,在写入数据库时使用INSERT OR REPLACE;同时在house_dedup表里以(house_id, fetch_date)做主键,实现同一日期的唯一性。
重复数据最隐蔽的影响还不是主键冲突,而是统计口径被放大。如果你在聚合时没有去重,某个板块的挂牌总量会被多算一次,均价虽然不变,但房源分布图完全失真。所以在清洗模块里,我会单独跑一个重复率检查:计算house_id的重复数量占总量的比例,超过2%就要查一下列表页是不是漏了去重逻辑。
5.6 现象:行政区划分不一致
同一个小区在不同页面里一会儿属于“城西板块”,一会儿属于“城西”,可视化时被分成两批。原因:平台的后台区划和前端展示字段不完全一致,部分板块类目有别名。解决:维护一个板块别名映射表,清洗时统一归并;如果还想精确到板块,建议把小区名和板块名单独建模,用小区名做最细粒度,板块和行政区作为派生维度。这个坑是二手房数据分析里最隐蔽的,数据表面看起来都是数字,但地理维度一旦歪掉,所有区域对比都会失去意义。
板块别名表要跟着采集进度不断扩充。第一次跑可能只有10个别名,跑一个月后可能会积累到30个。不要试图一开始就建全,边跑边补。每次在可视化里看到“其他”分组异常大时,就去翻一下原始数据,看看是哪些名称没有被映射。这种维护工作看起来机械,但正是它决定了分析结论靠不靠谱。
6. 把脚本升级成可查看的报告:本地Web可视化的一个最简做法
用Pyecharts生成HTML已经是可用的,但如果你想让同事在浏览器里随时查看,而不是每次重新打开文件,可以加一个极简Flask服务。这个服务只做两件事:读取SQLite里的最新快照,然后用一个路由返回整合后的页面。先把图表函数抽出来,放到analysis/charts.py里,保证Flask路由里没有一大堆逻辑。
# app.py from flask import Flask from pyecharts.charts import Bar from pyecharts import options as opts import sqlite3 import pandas as pd app = Flask(__name__) @app.route("/") def index(): conn = sqlite3.connect("data/hangzhou_house.db") df = pd.read_sql("SELECT district, unit_price FROM house_dedup", conn) grouped = df.groupby("district")["unit_price"].mean().sort_values(ascending=False) bar = ( Bar() .add_xaxis(grouped.index.tolist()) .add_yaxis("均价", grouped.round().tolist()) .set_global_opts(title_opts=opts.TitleOpts("杭州二手房挂牌均价看板")) ) return bar.render_embed() if __name__ == "__main__": app.run(host="127.0.0.1", port=8000, debug=True)render_embed()会直接把图表所需的JS代码嵌入HTML响应,省去额外引用静态文件,是最适合本地小工具的做法。访问http://127.0.0.1:8000就能看到最新图表。这里我用Pandas的groupby做聚合,是因为数据量还不大,等数据超过几十万行再考虑用数据库端聚合。
如果你把这个方向继续做下去,我建议再补两个验证手段:第一,手动抽样10套房源,在目标平台上核对抓取后的小区、单价、面积是否一致;第二,让数据库里保留至少两次采集的每日快照,用来核对价格变动是否合理。二手房数据最大的风险就是安静地错,图表生成得很漂亮,但数据口径已经偏了。
以前我跑类似项目时吃过一次亏:直接把“楼层低”当成“低楼层”,把“楼层高”当成“高楼层”,结果可视化里中楼层占比少了一截。后来养成了习惯,每次抓完新字段先打印值分布,出现异常类别就回去看采集逻辑。数据采集和可视化分析这套流程的价值,恰恰就在这些容易翻车的小细节里。希望这套模块划分和避坑经验能帮到你,少走一段弯路。
本文还有配套的精品资源,点击获取