1. 为什么我想抓二手房房价:一个看房人的偷懒方案
先交代一下背景。去年我一直在看房,目标锁定西安的几个热点板块。中介推过来的房源清单,永远只有“降价XX万”“业主急售”这种话术,真正想看的挂牌价分布、同小区不同楼层的价差、近一年价格走势,反而没人能给我一份干净的表格。更别说把“曲江一期”“高新软件新城”“浐灞三角洲”这些板块放一起比一比,到底哪里性价比高。
与其被中介带着节奏走,不如自己拿数据说话。我就用Python写了一套二手房房价信息爬取及可视化分析系统,把目标城市的挂牌数据抓下来,清洗成结构化表格,再做价格分布、区域对比、面积单价关系这些分析。整个过程基本覆盖了Python爬虫和数据分析的常用技术栈,对想练手的朋友来说是个完整度很高的实战项目。
这套系统做什么,一句话讲清楚:输入城市和板块关键词,自动抓取二手房挂牌房源的总价、单价、面积、朝向、楼层、装修、建筑年份这些字段,落库成CSV或Excel,再基于pandas做清洗,最后用matplotlib生成可视化报表。适合三类人来参考:一是像我一样有真实看房需求的人,二是正在找爬虫+数据分析练手项目的初学者,三是想给毕业设计找个完整案例的学生。
我用的是最稳妥也最好上手的组合:requests负责发起HTTP请求,BeautifulSoup解析HTML,pandas做数据处理,matplotlib画图。全部是Python生态里最基础的工具,不依赖任何重量级框架,换一台机器装上Python环境就能跑。整个项目从零到出图大概两千行代码,完整跑一遍采集一千条房源数据大约是二十到三十分钟,速度完全够用。
2. 目标网站的选择逻辑:为什么是链家而不是贝壳或安居客
爬虫项目第一步不是写代码,是选数据源。我在对比链家、贝壳、安居客之后,最终把主数据源定在链家的二手房频道,这个选择不是拍脑袋,背后有几个很实际的原因。
2.1 三个平台的反爬强度和数据结构对比
先说我试过的结果。贝壳和链家本质上是一套房源体系,但贝壳的页面数据大量通过接口异步加载,部分关键字段藏在加密的JSON里,直接抓HTML拿不到干净数据。安居客的反爬比较激进,访问频率稍微高一点就会弹验证码,对初学者非常不友好。链家的二手房列表页是服务端渲染,房源信息的核心字段直接写在HTML结构里,requests拿到页面源码后用BeautifulSoup就能解析出来,反爬门槛属于“有点考验但不过分”的水平。
链家还有一个好处是数据字段极其规整。每套房源的卡片区域里,标题、位置、房屋信息(几室几厅几平)、总价、单价,都是固定的class名称,解析规则写一次后面全部复用。对比过安居客那种同一个字段在不同页面用不同class的情况,链家的数据结构对爬虫开发者友好太多了。
2.2 城市和板块的选择策略
链家每个城市一个独立二级域名,比如西安是xa.lianjia.com,北京是bj.lianjia.com。我在系统里把城市代码抽成了配置文件,换城市只改一个参数。
板块划分方面,链家用的是“城区-板块”二级结构。以西安为例,城区下面还细分了几十个板块,比如高新区的软件新城、丈八北路,曲江新区的曲江一期、曲江二期。采集的时候我建议按板块入口进入,比如https://xa.lianjia.com/ershoufang/gaoxin/,这样拿到的数据天然带区域标签,后面做区域对比分析不需要额外做地理编码,省了很多事。
提示:链家每个城市、每个板块的URL规则基本一致,格式是
https://[城市代码].lianjia.com/ershoufang/[板块拼音]/。如果目标城市不在列表页的显眼位置,可以直接在搜索框里搜小区名,从结果页反推URL规则。
2.3 爬取范围的边界设定
二手房信息爬取要有一个边界意识。我给自己定的规则是:只抓列表页公开的挂牌摘要信息,不点进每套房源详情页抓业主手机号、带看记录这类强隐私数据。原因有两个,一是详情页反爬强度高一个量级,频繁请求大概率被封IP;二是从合规角度讲,公开挂牌信息用于个人分析没问题,但抓联系方式并批量使用就已经越界了。
这个边界也直接影响了系统设计——我只需要列表页的十个字段,根本不需要详情页。这个决定让爬虫的请求量大幅下降,抓一千条数据只需要请求二十多个列表页,压力很小,被封的风险也低很多。
3. 爬虫模块的落地细节:从请求伪装到字段解析
选定了链家之后,接下来就是爬虫主体部分。这里我会把每个环节的代码逻辑和背后的考虑讲清楚,尤其是字体反爬这个坑,卡了我一整个晚上。
3.1 请求头的伪装策略
写爬虫第一件事是让服务器觉得你是个正常人。我维护了一个请求头池,每次请求随机挑选一组User-Agent,同时带上Referer和Cookie。
import requests import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" ] def get_headers(): return { "User-Agent": random.choice(UA_POOL), "Referer": "https://xa.lianjia.com/", "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" }这里有两个细节容易被新手忽略。第一,Referer很重要,很多网站的防盗链逻辑会校验这个字段,不带Referer的请求会被直接拒绝。第二,用requests.Session()保持会话,让Cookie在多次请求之间复用,可以降低被识别为脚本的概率。
session = requests.Session() def fetch_page(url): try: resp = session.get(url, headers=get_headers(), timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text except requests.RequestException as e: print(f"[ERROR] 请求失败: {url}, 原因: {e}") return None3.2 分页循环的完整逻辑
链家的二手房列表页每页三十条房源,超过一百页会强制跳回第一页,所以单板块最多能抓三千条。我的分页逻辑很简单,构建页码URL循环请求,遇到空页面就停止。
def crawl_district(district_url, max_pages=100): all_records = [] for page in range(1, max_pages + 1): if page == 1: url = district_url else: url = f"{district_url}pg{page}/" html = fetch_page(url) if not html: break records = parse_list_page(html) if not records: print(f"[INFO] 第{page}页无数据,停止抓取") break all_records.extend(records) time.sleep(random.uniform(1.5, 3.5)) # 请求间隔,务必保留 return all_recordstime.sleep(random.uniform(1.5, 3.5))这一行是我认为整个爬虫里最重要的代码之一。很多人的爬虫被封,不是因为技术不行,是因为请求频率太规律了。随机间隔两三秒,让流量看起来像真人浏览,比任何UA伪装都有效。
3.3 用BeautifulSoup解析列表页字段
列表页的每张房源卡片结构如下:标题在div.title a,位置信息在div.flood,房屋基础信息在div.address下的div.houseInfo,总价和单价在div.totalPrice和div.unitPrice。
from bs4 import BeautifulSoup def parse_list_page(html): soup = BeautifulSoup(html, "html.parser") items = soup.select("li.clear") records = [] for item in items: title_tag = item.select_one("div.title a") if not title_tag: continue title = title_tag.get_text(strip=True) house_info = item.select_one("div.houseInfo") total_price = item.select_one("div.totalPrice span") unit_price = item.select_one("div.unitPrice span") records.append({ "title": title, "house_info": house_info.get_text(strip=True) if house_info else "", "total_price": total_price.get_text(strip=True) if total_price else "", "unit_price": unit_price.get_text(strip=True) if unit_price else "" }) return records到这一步能拿到原始字段,但还不能直接用。比如house_info是“3室2厅|113.4平米|南 北|精装|低楼层(共33层)|2018年建|板楼|有电梯”这种长字符串,要拆成结构化字段,这个放到下一节细说。
3.4 链家字体反爬的实战处理
这个坑值得单独写一段。链家网页上的数字有一部分不是普通文本,而是通过自定义字体映射的。你在浏览器里看到的是“113”,但requests拿回来的HTML源码里对应位置可能是一个CSS类名,比如<span class="num">.</span>,配一个font-family: lianjia_num的样式,实际字形通过@font-face引用的字体文件渲染出来。
我第一次抓完发现面积字段全是空或者乱码,排查了很久才发现是字体反爬。处理思路有三种:
第一种,也是最常用的,下载字体文件,用fontTools库解析cmap表,建立字符映射。这个方法最稳定,但代码量稍大。
from fontTools.ttLib import TTFont import io def parse_font(font_url): resp = requests.get(font_url, headers=get_headers()) font = TTFont(io.BytesIO(resp.content)) cmap = font.getBestCmap() # cmap的key是字符编码,value是字形名称,通过字形名称反推数字 # 链家的字体通常把数字0-9映射到uniE2B2之类的私有编码 return {glyph_name: str(digit) for digit, glyph_name in [(i, cmap[0xE000 + i]) for i in range(10)]}第二种方案更取巧——直接不解析列表页里受字体反爬保护的字段。我测试过,链家列表页里总价字段是纯文本节点,只有面积等少数数字走了字体,而面积字段在很多页面又不走字体。所以我后来调整了解析策略:优先从house_info里的纯文本提取面积,万一拿不到再按字体映射反推。
第三种方案,直接把页面截图下来人工看,这当然不实现不了自动化,但这也说明一个问题——完全不知道对方字体怎么造的时候,先退一步看看有没有别的字段来源。
注意:链家的字体映射并不是每天固定不变的,我观察过一段时间,基本每周会换一次映射关系。所以如果你保存了昨天的字体解析表,今天重新抓数据发现数字对不上,不用慌,重新下载字体文件重新解析就行。
3.5 异常处理和断点续抓的设计
爬虫最忌讳的是跑到一半挂了,全部重来。我在系统里加了一个简单的断点续抓机制:每成功解析一页,就把当前页码写入一个本地进度文件,下次启动时先读进度文件,从上次结束的页码继续。
import os import json PROGRESS_FILE = "crawl_progress.json" def save_progress(district, page): data = {} if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, "r") as f: data = json.load(f) data[district] = page with open(PROGRESS_FILE, "w") as f: json.dump(data, f) def load_progress(district): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, "r") as f: data = json.load(f) return data.get(district, 1) return 1另外一个兜底策略:如果连续三次请求失败,就强制休眠六十秒再重试,优先级高于随机休眠。这样即使某个时间点IP被临时限流,等一会儿之后还能自动恢复,不用人工盯着重启。
4. 数据清洗:把爬下来的“长字符串”变成能分析的表格
爬虫只是第一步,真正费时间的是清洗。链家列表页返回的原始数据,每个字段都带着展示层的包装,直接拿去做分析根本不行。这一节讲我怎么把脏数据变成干净的结构化表格。
4.1 挂牌总价和单价的清洗
链家的总价字段长这样:“230万”,单价是“21302元/平”。pandas处理这个很容易,但要注意单位换算和类型转换。
def clean_price(price_str): """把'230万'转成230.0""" if not price_str: return None return float(price_str.replace("万", "")) def clean_unit_price(price_str): """把'21302元/平'转成21302""" if not price_str: return None return float(price_str.replace("元/平", "").replace(",", ""))单价字段里有时候会带千分位逗号,比如“21,302元/平”,不先把逗号去掉就直接float()会报错。这个坑我踩过,现在习惯了所有数字清洗第一件事就是先剔除分隔符。
4.2 房屋信息字段的结构化拆解
house_info这个字段是最麻烦的,它的分隔符是竖线|,但不同房源的分段数量不完全一样。典型值是“3室2厅|113.4平米|南 北|精装|低楼层(共33层)|2018年建|板楼|有电梯”。
我做的拆解逻辑是先按|切分,然后通过关键词匹配来识别每一段是什么含义。比如包含“室”和“厅”的是户型,包含“平米”的是面积,包含“楼层”和“共”的是楼层信息,包含“年建”的是建筑年份。
import re def parse_house_info(house_info): if not house_info: return {} parts = house_info.split("|") result = { "bedrooms": None, "living_rooms": None, "area": None, "orientation": None, "decoration": None, "floor_level": None, "total_floors": None, "build_year": None, "building_type": None, "has_elevator": None } for part in parts: if "室" in part and "厅" in part: match = re.search(r"(\d+)室(\d+)厅", part) if match: result["bedrooms"] = int(match.group(1)) result["living_rooms"] = int(match.group(2)) elif "平米" in part: match = re.search(r"([\d.]+)平米", part) if match: result["area"] = float(match.group(1)) elif "南" in part or "北" in part or "东" in part or "西" in part: result["orientation"] = part.strip() elif "精装" in part or "简装" in part or "毛坯" in part or "豪华" in part: result["decoration"] = part.strip() elif "楼层" in part: floor_match = re.search(r"(.+?楼层)\(共(\d+)层\)", part) if floor_match: result["floor_level"] = floor_match.group(1) result["total_floors"] = int(floor_match.group(2)) elif "年建" in part: match = re.search(r"(\d{4})年建", part) if match: result["build_year"] = int(match.group(1)) elif "板楼" in part or "塔楼" in part: result["building_type"] = part.strip() elif "电梯" in part: result["has_elevator"] = "有电梯" if "有" in part else "无电梯" return result4.3 楼层类型的二次处理
链家把楼层分成“低楼层”“中楼层”“高楼层”,这属于相对楼层。做分析的时候,我更喜欢相对楼层和总楼层的比值,这样能看出真实的高度位置。比如总楼层33层的楼里,低楼层可能是1到10层,但我拿不到具体层数,只能退而求其次用相对楼层做分组。
如果你确实需要绝对楼层,可以点进详情页抓楼层数字字段,但那样请求量会暴涨。我的建议是,做区域均价这种宏观分析用相对楼层就够了,没必要为了这个把自己暴露在封号风险里。
4.4 去重和缺失值处理
同一个房源可能在列表页出现两次(比如业主在不同中介公司挂了同一套房),要做去重。我用的去重键是“小区名+户型+面积+总价”,这四个字段组合起来基本不会误杀。
df = df.drop_duplicates(subset=["community", "bedrooms", "living_rooms", "area", "total_price"])缺失值处理方面,我的原则是“能补则补,不能补就删”。比如朝向缺失的房源占比不到百分之一,直接删掉这些行对分析结果影响微乎其微。但如果一个板块有超过百分之二十的房源缺建筑年份,那就要怀疑是不是解析规则出了问题,而不是数据本身缺失。
5. 可视化分析:从表格到一眼能看懂的图
数据清洗完之后,pandas里已经有了一份结构化的房源表,接下来就是可视化。matplotlib是Python里最基础也是最适合这个项目的绘图库,虽然好看程度不如ECharts,但胜在不用开浏览器,脚本跑完直接出图。
5.1 四宫格总览图的设计思路
我习惯先画一张四宫格总览图,把最关键的信息一次性呈现出来。四张子图分别是:总价分布直方图、区域均价条形图、面积与总价的散点图、建筑年份分布图。
import matplotlib.pyplot as plt import pandas as pd fig, axes = plt.subplots(2, 2, figsize=(16, 12)) # 1. 总价分布直方图 axes[0, 0].hist(df["total_price"], bins=30, color="#4C72B0", alpha=0.8) axes[0, 0].set_title("二手房总价分布", fontsize=14) axes[0, 0].set_xlabel("总价(万元)") axes[0, 0].set_ylabel("房源数量") axes[0, 0].axvline(df["total_price"].median(), color="red", linestyle="--", label=f"中位数 {df['total_price'].median():.0f}万") axes[0, 0].legend() # 2. 区域均价条形图 district_avg = df.groupby("district")["unit_price"].median().sort_values(ascending=False) axes[0, 1].barh(district_avg.index, district_avg.values, color="#55A868") axes[0, 1].set_title("各板块单价中位数", fontsize=14) axes[0, 1].set_xlabel("单价(元/平)") axes[0, 1].invert_yaxis() # 3. 面积与总价散点图 axes[1, 0].scatter(df["area"], df["total_price"], alpha=0.5, s=10, color="#C44E52") axes[1, 0].set_title("面积与总价关系", fontsize=14) axes[1, 0].set_xlabel("面积(平米)") axes[1, 0].set_ylabel("总价(万元)") # 4. 建筑年份分布 axes[1, 1].hist(df["build_year"].dropna(), bins=20, color="#8172B2", alpha=0.8) axes[1, 1].set_title("建筑年份分布", fontsize=14) axes[1, 1].set_xlabel("建筑年份") axes[1, 1].set_ylabel("房源数量") plt.tight_layout() plt.savefig("overview.png", dpi=150, bbox_inches="tight") plt.show()5.2 为什么用中位数而不是平均值
区域对比这里我刻意用中位数而不是平均值,这是个很多人忽略的细节。房价数据是典型的右偏分布,少数几套千万级豪宅会把平均值拉得很高,导致区域被高估。中位数抗极端值干扰,更能代表这个区域的“普通房源”水准。我对比过同一个板块的均价和中位数,能差到每平两三千块,这个差距会直接影响你对一个板块购买力的判断。
5.3 先算好数据再画图
可视化最容易犯的错误是直接在DataFrame上反复算聚合。实际上,matplotlib只负责画图,统计计算应该提前完成。我的习惯是先算好一个绘图专用的DataFrame,再丢给matplotlib画。
plot_df = df.groupby("district").agg( avg_total_price=("total_price", "mean"), median_unit_price=("unit_price", "median"), listing_count=("title", "count") ).reset_index()做预算分析的时候,我还会再加一张“预算范围内房源占比图”。比如设定三百万以下为目标范围,计算每个板块有多少房源落在这个区间,这样能直观看到哪个板块“上车容易”。
5.4 中文显示问题的处理
matplotlib默认字体不支持中文,直接画图会出现方框。解决办法是在画图前设置中文字体,macOS和Windows路径不同。
import matplotlib matplotlib.rcParams["font.sans-serif"] = ["Arial Unicode MS", "SimHei", "Microsoft YaHei"] matplotlib.rcParams["axes.unicode_minus"] = Falseaxes.unicode_minus这个配置容易漏,不设置的话坐标轴的负号会显示成方块。反正中文字体和负号这两个配置我是固定写在一起的。
6. 分析结果怎么读:实操中我发现的数据规律与局限性
系统跑通之后,我拿西安几个板块的数据做了一轮实际分析,这里分享几个有意思的发现,顺便也得聊聊这套方法的局限性。
6.1 三个真实的数据规律
第一个规律,面积和单价的负相关关系比想象中明显。我抓到的数据里,九十平米以下的房源单价普遍高于一百三十平米以上的大户型,折算下来小户型每平米能贵出百分之十五左右。这个现象在成熟板块尤其突出,因为小户型总价门槛低,接盘的人多,卖家定价空间就大。
第二个规律,中楼层房源占比高且单价坚挺。链家数据里中间楼层的房源数量最多,这跟实际供需结构吻合——大多数购房者偏好中楼层,卖得动,中介也愿意重点推。低楼层和高楼层的挂牌价差距大概在百分之五到百分之八之间。
第三个规律,建筑年份对房价的影响不是线性的。2010年以前的老房子和2015年以后的新房子差价明显,但2015到2020年之间的房源价格差异反而没那么大。这说明市场对“房子新旧”的敏感度集中在房龄超过十年的节点上。
6.2 这套系统的四个已知局限
第一,挂牌价不等于成交价。链家承若的挂牌价是业主的心理预期,实际成交往往有百分之三到五的议价空间。我后来用链家APP的成交记录频道做对比,发现成交价平均比挂牌价低百分之四点二左右。如果做预算规划,要在挂牌价基础上预留议价空间。
第二,数据结构天然有偏差。链家主要覆盖城市建成区,远郊和新区房源数量少,很多板块甚至没有收录。这意味着你分析的“西安市二手房”实际上是“链家覆盖范围内的西安市二手房”,不是全量数据。
第三,爬取时间窗口影响分析结论。我是某个月集中抓的,但房价是动态变化的,急售房源可能在一周内下架,新房源也持续上架。如果你要做趋势分析,需要定期重复抓取,比如每月一次,再纵向对比。
第四,单城市的结论不能外推。不同城市的房地产市场逻辑差异极大,我在西安看到的小户型溢价规律,放到一线城市未必成立,甚至同一城市的不同板块可能相反。所以做跨城市对比的时候,一定要复用同一套爬取和清洗逻辑,而不是拿着一个城市的分析结论去做另一个城市的判断。
6.3 后续可以扩展的方向
这套系统的下一步,我打算做几个扩展。一是加入时间维度,用定时任务每月抓一次,累积六个月后画价格趋势线。二是引入地图可视化,用folium把房源标注在地图上,按价格区间着色,能看到板块之间的价格断层。三是增加筛选交互,做一个简单的Streamlit页面,让用户选择板块、预算和面积范围,动态生成对比图表。
回到最开始那个看房的需求,这套系统最大的价值不是帮我找到了哪套房,而是让我在看房的时候心里有了一杆秤。中介报出一个价格,我能立刻判断这个价格在这个板块里处于什么分位,周围同类房源大概什么价位,值不值得继续谈。这种底气,是从表格和图里长出来的。