Python爬虫+Geopy:从二手房数据挖掘房价与地理位置的关系
2026/9/18 5:06:26 网站建设 项目流程

干了几年数据采集和城市数据分析的活儿,我一直有个特别直观的感受:爬虫本身不难,难得是把数据爬下来之后,你能从里面看见什么。以前接过一个二手房相关的分析需求,当时手上攒了三千多条挂牌房源,字段无非是小区名称、户型面积、朝向楼层、总价单价。表格拉到一半眼睛就花了,除了知道“市中心贵、郊区便宜”这种废话之外,基本得不出任何能让人眼前一亮的结构性结论。

后来把快烂在Excel里的数据接进Python,用Geopy把每个小区的名字换算成经纬度,再算一算到地铁站、到商圈核心的直线距离,整个事情突然就变得不一样了。房价和地理位置之间的“隐藏关系”根本不是简单一句“地段决定价值”能概括的,它藏在距离衰减曲线、配套密度和板块分化这些细节里。这篇博文就把我这套“Python爬虫+Geopy”的组合玩法完整拆一遍,包括抓取思路、地理编码的工程细节、相关性测算方法,以及我在实际操作中踩过的坑。适合刚学完Python基础、想拿真实项目练手的读者,也适合已经写过几个爬虫但想做更深层数据分析的朋友。

1. 挂牌数不等于房价真相:地理位置才是关联杠杆

1.1 为什么同一板块挂牌价差距巨大

先把一个很容易被忽略的事儿说明白——我们平时在房源APP上看到的挂牌价,其实是一堆极其“不均匀”的样本。同一个板块里,有2000年以前的楼梯房,有2021年交付的次新电梯房,有靠马路的噪声房,也有小区中心花园边的楼王。如果不引入地理位置坐标,只按“板块”这个行政或商业概念做聚合,那么板块内部的方差很可能会吞掉真正的规律。

我举个例子。假设某个板块A,平均挂牌单价是3.2万/平米,但如果你把每套房子的经纬度画出来,再把单价渲染成颜色,会发现从板块东北角到西南角,单价沿着一条主干道呈现出明显的下降。原因是东北角紧挨着一个开通不久的地铁换乘站,而西南角是老旧工业区改造的安置房片区。单纯看“板块均价”,你会以为整个板块都值这个价,但实际上真正支撑价格的只是那一小块地。

这就是我坚持在分析链路里引入Geopy的核心原因——地理位置不是一列冷冰冰的字段,它是所有房源销售述求和买家决策的底层编码。没有坐标的房价分析,等于只看了故事的封面。

1.2 地理信息能给数据叠加的三层价值

把地理位置信息加进房价分析,通常能带来三层递进式价值:

  • 距离维度的量化:每一套房源到地铁站、商圈、学校、医院的距离都可以被计算出来,距离变量可以替代模糊的“板块”概念,成为回归或相关性分析中的连续特征。这一点对建模非常重要,因为连续变量比离散的行政区划更能暴露真实规律。
  • 空间分布的直观性:通过结合底图的可视化,人类视觉系统能快速识别高值聚集区、价格断裂带、沿交通线延伸的“溢价走廊”。这种模式在纯表格里几乎不可能被发现。
  • 比价与套利逻辑:相邻小区、甚至同一小区的不同期楼栋,因为地理位置微小的差异,可能出现每平米几千块的差价。坐标对齐后,这种“相邻高差”会非常刺眼,也更有分析价值。

明白了这层关系,后面所有技术操作——爬取、清洗、地理编码、可视化——就都有了明确的目的,不再是为了跑通代码而跑通代码。

2. 房源页面抓取:从字段设计到反爬节奏控制

2.1 目标网站选择与列表页结构分析

开始写爬虫之前,得先想清楚从哪儿拿数据。我个人的选源标准有四条:

  1. 列表页能拿到足够详细的房源字段,减少二次请求的次数;
  2. 页面结构稳定,不要今天改版明天变样;
  3. 房源信息更新相对及时,最好有明确的挂牌时间;
  4. robots规则和访问频率控制友好,至少别逼着人上各种强对抗手段。

很多综合房产平台的二手房列表页,通常会展示小区名称、几室几厅、面积、朝向、楼层、总价、单价。这些字段足够做第一轮分析。但要拿到更细的“建筑年代”“物业类型”或“附近配套”,就得进详情页。我的建议是:先评估“能不能不做详情页请求”,尽量用列表页解决战场。因为详情页请求量动辄翻几倍,被限制的风险也随之上升。

拿到列表页之后,先用浏览器的开发者工具看一眼页面结构。现在的房源列表大部分是服务端渲染配合部分异步加载,数据可能嵌在HTML里,也可能从接口返回JSON。判断方法很简单:用Python的requests把页面拉下来,然后搜一个房源标题里特有的字符串,如果能在HTML源码里找到,那就是服务端渲染,用解析库直接处理;如果找不到,就得去Network面板里找XHR请求了。

2.2 请求策略、解析逻辑与字段落地

这里给一套我常用的最小可行代码框架,配合注释说明每一步的意图。利用requests获取页面,配合解析HTML。

import requests from bs4 import BeautifulSoup import pandas as pd import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "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.5", } def fetch_list_page(city_code, page_no): """抓取某个城市二手房的列表页,返回HTML文本""" url = f"https://example-realestate.com/{city_code}/ershoufang/pg{page_no}/" resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text def parse_list_page(html): """从列表页HTML中解析房源字段""" soup = BeautifulSoup(html, "html.parser") items = [] for li in soup.select(".house-item"): title_node = li.select_one(".title a") if not title_node: continue title = title_node.text.strip() community = li.select_one(".community").text.strip() house_info = li.select_one(".house-info").text.strip().split("|") total_price = li.select_one(".total-price").text.strip() unit_price = li.select_one(".unit-price").text.strip() items.append({ "title": title, "community": community, "layout": house_info[0].strip() if len(house_info) > 0 else "", "area": parse_area(house_info[1]) if len(house_info) > 1 else None, "orientation": house_info[2].strip() if len(house_info) > 2 else "", "total_price": parse_price(total_price), "unit_price": parse_price(unit_price), }) return items

这只是演示性质的伪代码,不同的网站需要替换选择器。写解析逻辑的时候,有一个很重要的原则:每个字段都要单独写一个小函数做清洗,不要直接在解析列表推导里塞一堆replace比如面积字段,原始文本可能是“89.5平米”或者“89.5㎡”,统一转成浮点数;总价字段可能是“650万”也可能是“6,500,000”,要转成以“万”为单位的数值。把这些清洗逻辑集中起来,后面做数据分析会省很多事。

2.3 增量抓取与去重机制

爬数据最怕的不是慢,而是重复和脏。尤其是二手房这种“同一套房挂牌信息每天变”的场景,如果没有唯一标识,后期去重就是灾难。

我建议在字段设计阶段就给每套房源留一个house_id字段,通常列表页链接里会带一长串数字编号,把它提取出来作为主键。抓取时先读一下本地已有的主键集合,新数据只有在这个集合里不存在时才写入。这套逻辑用十几行就能实现:

import os SAVED_IDS = set() DATA_PATH = "houses.csv" if os.path.exists(DATA_PATH): df_old = pd.read_csv(DATA_PATH) SAVED_IDS = set(df_old["house_id"].astype(str)) new_rows = [] for page_no in range(1, 101): html = fetch_list_page("某市", page_no) items = parse_list_page(html) for item in items: hid = item["house_id"] if hid not in SAVED_IDS: new_rows.append(item) SAVED_IDS.add(hid) # 关键:每次请求之间随机sleep,避免固定频率触发反爬 time.sleep(random.uniform(1.5, 4.0))

两次请求之间的停顿是给你自己留退路的操作。别小看这个随机sleep,很多反爬策略第一步就是检测请求间间隔是否完全规律。你把自己伪装成一个“有点磨蹭但还算正常”的用户,比那种毫秒级扫页的脚本存活率高得多。

3. Geopy地理编码:把小区地址换算成经纬度的工程细节

3.1 地址标准化:清洗不是小事

拿到房源数据之后,第二个大工程就是把“小区名称”变成经纬度。我最早犯过的错是直接把“某小区”这种名字扔给地理编码服务,结果大量请求返回歧义结果:全国叫“阳光花园”的小区可能有几十个,光靠名字根本无法确定到底是谁。

所以做地理编码前,必须先做地址标准化。通常要把“城市 + 行政区 + 板块 + 小区名”拼成一个完整地址字符串。比如“某市 朝阳路街道 远大板块 阳光花园二期”。但很多时候列表页只给了小区名,行政区与板块要从当前列表页的URL参数或面包屑导航里提取。这批数据得在抓取阶段就跟着一起存下来,等到了编码阶段再拆分,链条容易断。

另外一个容易踩坑的点是小区名里的各种后缀噪声,比如“阳光花园二期”“阳光花园B区”“阳光花园东区”,这些在后缀上不同的名,对地理编码来说往往指向同一个坐标。所以我一般会先做一步标准化:把“一期/二期/三期”“东区/西区/南区/北区”“A/B/C栋”里的这些规则词去掉,先生成一个community_base字段用于编码,同时保留原始名称用于展示。

3.2 Nominatim调用与并发控制

Geopy本身不是一个地理编码服务,它是一个封装了多个地理编码服务接口的Python库,最常用的是OpenStreetMap的Nominatim。调用方式非常简洁:

from geopy.geocoders import Nominatim from geopy.distance import geodesic geolocator = Nominatim(user_agent="housing_analysis_demo/1.0 (your_email@example.com)") def geocode_address(address, retries=3): for i in range(retries): try: location = geolocator.geocode(address, timeout=10) if location: return location.latitude, location.longitude except Exception as e: print(f"第{i+1}次尝试失败: {address}, {e}") time.sleep(2) return None, None

这里有两个必须注意的点。

第一,user_agent必须按照官方要求改成自己的应用名称和联系方式,一堆爬虫脚本都用默认的geopy/2.x,很容易被服务端直接拒掉。第二,Nominatim的公共实例有严格的频率限制,官方要求是每秒最多1次请求。如果房源样本上万条,建议用本地OSM数据库,或者换用Mapbox/Baidu/高德等付费或认证服务,不要把公共Nominatim当成生产级工具来用。

我实际处理5000条房源时,用单线程加sleep的方式跑完大概花了两个多小时。这个速度确实不快,但胜在稳定。如果你实在要提速,可以开多进程,但每个进程的请求频率加起来还是不能超过服务限制,否则IP被临时封了更浪费时间。

3.3 坐标系、坐标偏转与可视化对齐

地理编码得到的坐标是WGS84坐标系,也就是GPS使用的国际标准。但国内大多数地图底图,在实际叠加标注时用的可能是GCJ-02或者BD-09坐标系。直接把WGS84坐标撒到某些国内地图SDK上,会出现几十米到几百米不等的偏移,在住区密集区域很容易把属于A小区的点落到B小区头上。

解决方式也很直接:要么在采集阶段直接用平台自带的位置信息,如果平台页面里标注了经纬度,就直接用平台坐标,不要自己再编码一遍;要么在做可视化时,选择支持WGS84的底图来源,比如光栅切片方式加载OSM底图,或者自己做一个坐标纠偏的转换函数,把WGS84转成GCJ-02。

我在实际项目中会同时存两套坐标:一套是地理编码得到的原始坐标,用于距离计算;一套是转换后的展示坐标,用于底图叠加。距离计算用原始坐标的好处是,所有房源在一个统一的坐标系下,距离度量不打架,可视化再单独做坐标转换。不要为了可视化方便,把距离分析也建立在偏移过的坐标上,那样算出来的“距离”很容易掩盖真实的地理关系。

4. 距离、通勤、配套:三个与房价强相关的地理特征测算

坐标到手之后,数据分析才真正开始。给你分享三个我实际验证过、和房价相关性最稳定的地理特征维度。

4.1 到商圈核心的距离

所有城市都有一个或几个公认的商圈核心,比如老城区的中央商业街,或者新区的CBD。这个核心点的坐标需要你手动确定——通常是当地租金最贵、写字楼最集中、或者商场人流最大的那一小块区域。

把每一套房源坐标和这个核心点坐标做球面距离计算,用Geopy一行就能搞定:

from geopy.distance import geodesic core_point = (39.9042, 116.4074) # 示例:用目标城市核心坐标替换 def distance_to_core(lat, lon): if lat is None or lon is None: return None return geodesic((lat, lon), core_point).kilometers

geodesic计算的是椭球体上的真实最短距离,比平面直角坐标系的欧氏距离更接近实际步行或行车的直线参考。虽然它不是道路距离,但在宏观趋势分析里,直线距离已经能解释相当大比例的房价变异。

分析时我建议把房源按照距离核心点的远近分成几个环带,比如0-3公里、3-6公里、6-10公里、10公里以上,然后分别统计各环带的单价中位数。这样看比直接画散点图要更加稳健,因为避免了个别极端房源对散点的干扰。

4.2 到地铁站步行距离

第二个高价值特征是“到最近地铁站的距离”。这里有的做法是直接调地图API算步行导航距离,但请求量大、成本高。一个折中的办法是:

  1. 用爬虫抓取当地所有地铁站点的名称和坐标(这一步通常比较轻松,一次性搞定);
  2. 对每一套房源,计算它到所有地铁站的最小直线距离;
  3. 如果想更精细,可以用OSM路网数据做一次离线步行距离估算。

第2步的计算量看起来很大,但实际上用KD树或者scipy.spatial.distance.cdist来批量算,几万条数据也是秒级完成:

import numpy as np from scipy.spatial import cKDTree # metro_coords: 所有地铁站经纬度数组 # house_coords: 所有房源经纬度数组 tree = cKDTree(metro_coords) distances, indices = tree.query(house_coords, k=1)

灵魂拷问:直线距离2公里和步行距离2.5公里,对房价的影响方向一样吗?在大多数情况下方向一致,但量级会有偏差。如果项目要求比较严谨,建议至少抽样几十套房源,人工用地图App计算真实的步行时间,和直线距离做一个线性校准。不需要太准,能校准出“这个城市的直线距离折算步行时间的平均系数”就够了。

我测过的一个城市样本里,直线距离每减少100米,二手房挂牌单价大约上涨0.8%到1.5%,但这个系数在市中心和远郊差别很大。市中心的步行尺度竞争激烈,溢价敏感;远郊人们更依赖地铁,地铁距离的影响反而陡峭。这种非线性关系,正是简单说“离地铁越近越贵”的人看不透的地方。

4.3 周边POI密度(配套丰富度)

第三个特征是“配套密度”。这里的思路是:以每套房源为中心,画一个1公里或1.5公里的缓冲区,统计缓冲区里有多少个餐饮、购物、学校、医疗、休闲类POI点。POI数据的来源可以是公开的地图接口,也可以是自己爬取的兴趣点列表。

缓冲区分析用GeoPandas会比较顺手:

import geopandas as gpd from shapely.geometry import Point gdf = gpd.GeoDataFrame(houses, geometry=gpd.points_from_xy(houses["lon"], houses["lat"])) gdf.set_crs(epsg=4326, inplace=True) gdf = gdf.to_crs(epsg=3857) # 转投影坐标系,方便算面积 poi_gdf = gpd.GeoDataFrame(pois, geometry=gpd.points_from_xy(pois["lon"], pois["lat"])) poi_gdf.set_crs(epsg=4326, inplace=True) poi_gdf = poi_gdf.to_crs(epsg=3857) buffers = gdf.geometry.buffer(1000) # 1公里缓冲区

统计每个缓冲区内的POI数量之后,你会得到一个“配套分”字段。把配套分和单价做相关性分析,通常能看到比单纯距离更强的解释力,因为一套房周围的配套密度本身就是多重地理因素的综合结果。

5. 热力图与散点图谱:让隐藏关系自己浮现

5.1 单价热力图

距离计算完之后,一定要把结果画出来看。我最常做的一件事是画“单价热力图”。用Folium配合热力插件,可以直接把经纬度和单价数据叠加到交互式底图上:

from folium.plugins import HeatMap import folium m = folium.Map(location=[city_lat, city_lon], zoom_start=12) heat_data = [[row["lat"], row["lon"], row["unit_price"]] for _, row in houses.iterrows() if row["lat"]] HeatMap(heat_data, radius=18, blur=12, min_opacity=0.3).add_to(m) m.save("house_price_heatmap.html")

热力图是探索性分析里最直观的手段。你会非常清楚地看到房价的“脊骨”和“洼地”:某个片区的红色高亮可能沿着地铁线像脊柱一样延伸;某个看起来离中心不远的板块,却因为断头路、铁路切割或者大型公园阻隔,在图上形成一个明显的蓝色空洞。

上次我分析某个样本城市的数据时,就发现一个很有意思的现象:单价最高的区域并不是城市地理中心,而是在中心和地铁换乘大站之间的一小段弧线上。周边老破小多、路面狭窄、以小店为底商,离核心商圈也不是最近的,但就是贵。再细看,那段弧线上集中了三个新建的改善型楼盘,房龄新、得房率高、学区稳定,叠加上地铁通勤便利和成熟底商,才形成了这个“隐藏高价带”。

这种结论,如果不把价格渲染到地图上,只看表格里的均价排序,是得不出任何可操作判断的:数据样本会被淹没在均值之中,差异被平均掉,规律自然就隐藏起来了。

5.2 面积-总价-距离散点组合

热力图适合看空间分布,但要看变量之间的相互作用,还是得回归到散点图和二维分面。

我常用的一个组合是:

  • X轴:到核心的距离
  • Y轴:总价
  • 点大小:面积
  • 点颜色:单价
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(12, 8)) sc = ax.scatter( houses["dist_to_core"], houses["total_price"], s=houses["area"] * 0.5, c=houses["unit_price"], cmap="viridis", alpha=0.6, ) plt.colorbar(sc, label="单价(元/平米)") ax.set_xlabel("到核心商圈直线距离(km)") ax.set_ylabel("总价(万元)") plt.show()

这个图能同时看出几个维度的信息。正常情况下,随着距离增加,总价应该总体下降,但是点的颜色(单价)和大小(面积)会告诉你这个下降到底是因为面积变小,还是因为单价变低。如果距离远、面积大、总价仍然坚挺,说明购买力正在向外围改善型区域迁移。

还有一招很实用:把画布按照市场总价分成几个区间。比如总价200万以下、200-400万、400-800万、800万以上,四张小分面图分别画距离-单价关系。不同总价段对距离的敏感度差别很明显,高端市场往往对距离不那么敏感,但对着资源稀缺度极其敏感;刚需市场则相反,距离几乎是生命线。这种结构性差异,只有分面下来才看得清。

6. 合规边界与数据质量的四个常见坑

6.1 robots与个人信息边界

写到这必须专门提醒一下合规问题。爬虫的合规边界不是“能访问就能爬”,而是要尊重目标网站的robots协议和使用条款。房源信息如果是公开挂牌信息,商家本身也希望通过搜索被获取,这类数据的采集风险相对较低,但依然要注意:

  • 采集频率不要影响目标网站的正常运维;
  • 抓下来的数据不要用于骚扰性推销或者变相骚扰;
  • 如果涉及联系人和电话信息,坚决不入库、不分析——个人联系方式不是你应该爬的字段。

我在做分析时,只保留房源属性、价格和位置这三类信息,联系人字段在解析阶段就直接丢弃。这既是自我保护,也是对数据伦理的基本尊重。

6.2 反爬识别与请求频率控制

现在的房源平台普遍有比较成熟的Web应用防火墙和UA识别、IP频率限制。我的建议是尽量用“低强度、长周期、分类别”的方式来取数。

比如每天固定跑两轮增量抓取,每轮间隔5~8秒,最多不超过100页。这样一天下来大概能更新几百到上千条房源,对分析来说完全够用。不要指望一次爬完整个城市几十万条数据,那种玩法很容易把自己的IP送进小黑屋。代理IP不是不能用,但那是个无底洞,成本高、维护麻烦,除非真要做大规模持续采集,否则不建议新手一上来就折腾代理池。

另外,请求失败一定要做退避策略。连续失败3次,立刻停下来,至少休息30分钟再继续。这个“停手”的功夫,比盲目重试更有效。我在脚本里通常设置一个指数退避的计数器,第一次失败等30秒,第二次等60秒,第三次等180秒,再失败就直接退出程序并发送通知。自动提醒能让你在被封之前及时止损。

6.3 房源数据撒谎:面积、报价、挂牌价与成交价的偏差

即使爬下来的数据再干净,也要知道它天生带偏。最大的一层偏差是:挂牌价不是成交价。房主的心理预期和市场真实成交之间往往有一道需要谈判才能磨平的缝隙。有些房主挂牌就是纯试水,价格高出市场两成,挂着几个月也不降价;有些房主急于出手,挂牌价已经低于最近成交价。这些都是挂牌数据里无法避免的噪声。

第二个常见问题是面积注水。不同年代的小区对“建筑面积”和“套内面积”的标注口径不一样,有的赠送阳台算一半面积,有的把地下室也摊进去。如果拿单价(总价/面积)作为主要因变量,要先做一步面积异常值清洗。我通常会把面积小于30平米或者大于300平米的房源单独看看,确认一下是不是车库、商铺混进来了。另外,单价低于5000或者高于同板块均值3倍的数据,也大概率是录入错误或者产权特殊(比如只有使用权),建议打上异常标记后再决定要不要剔掉。

6.4 地理编码失败后的兜底方案

地理编码一定会遇到失败的情况。地址写的太模糊、小区名太新、服务端返回结果偏差太大,这些都会造成坐标缺失或错标。我的兜底策略是分三层:

第一层,重试与候选修正。把搜索关键词从完整地址改为“小区名”,如果还是失败,就尝试去掉“小区”“花园”这类通名后缀,只保留核心地名。第二层,人工抽样修正。把所有未编码成功的地址列出来,用地图App快速查询一批,把坐标更新回数据表,命中率通常在70%以上。第三层,放弃与标记。剩余实在查不到的,保留到geocode_status字段里标为failed,分析的时候不参与地理特征计算,但不能整体删除整条房源——因为价格字段可能是可用的,只是位置维度缺失。

还有一点,地理编码返回的坐标不是绝对精准。Nominatim有时会把地址定位到小区入口附近,误差一般在几十米到一两百米级别。你要分析的是“到地铁站的直线距离”,这个误差会被放大。所以我自己对“离地铁站距离”这个特征,会在结果里加一个“±0.15km”的容错带,结论解读时不过度抠细节。


最后分享一个我自己的数据分析习惯:做完一轮房源数据的地理分析之后,不要着急下结论。样本量不够时,先把“异常值”单独列出来看,判断它们是录入错误、特殊房源,还是真正值得关注的趋势苗头。有一次我在数据里发现某个远离市中心的楼盘单价特别高,几乎和市中心齐平。最初以为是编码错误,后来交叉验证发现是本地一个著名的高端养老社区,带医疗配套,总价门槛极高,单价自然惊人。这种形态,恰恰又是“地理位置隐藏关系”的一种表现——它不在常规的区位逻辑里,但坐标一旦展开,它自己就跳出来了。

用Python抓取二手房数据只是第一步,把Geopy的距离空间算清楚、把热力图画出来,你才真正拥有了对一座城市房价形成机制的解释能力。也正因为如此,我始终觉得这种“爬虫+地理信息”的项目,比单纯写一百个通用爬虫Demo有意义得多。

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

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

立即咨询