㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐⭐
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [一、项目背景与目标 🎯](#一、项目背景与目标 🎯)
-
- [为什么要做 POI 数据分析? 🤔](#为什么要做 POI 数据分析? 🤔)
- [本次实战目标 📋](#本次实战目标 📋)
- [二、技术栈选型 🛠️](#二、技术栈选型 🛠️)
- [三、环境准备 💻](#三、环境准备 💻)
- [四、核心代码实现 🧑💻](#四、核心代码实现 🧑💻)
-
- [4.1 坐标转换器(coordinate/converter.py)](#4.1 坐标转换器(coordinate/converter.py))
- [4.2 行政区划查询器(geocoder/district.py)](#4.2 行政区划查询器(geocoder/district.py))
- [4.3 POI 搜索器(poi/searcher.py)](#4.3 POI 搜索器(poi/searcher.py))
- [4.4 POI 分类管理器(poi/classifier.py)](#4.4 POI 分类管理器(poi/classifier.py))
- [4.5 批量获取器(poi/batch_fetcher.py)](#4.5 批量获取器(poi/batch_fetcher.py))
- [4.6 热力图生成器(analyzer/heatmap.py)](#4.6 热力图生成器(analyzer/heatmap.py))
- [4.7 交互式地图可视化(visualizer/map_plotter.py)](#4.7 交互式地图可视化(visualizer/map_plotter.py))
- 五、完整示例:三里屯商圈分析
- [六、常见坑点与解决 ⚠️](#六、常见坑点与解决 ⚠️)
-
- [坑点 1:坐标系混用导致偏移](#坑点 1:坐标系混用导致偏移)
- [坑点 2:API 配额用完](#坑点 2:API 配额用完)
- [坑点 3:内存溢出(数据量大时)](#坑点 3:内存溢出(数据量大时))
- [七、生产环境优化 🚀](#七、生产环境优化 🚀)
-
- [7.1 增量更新策略](#7.1 增量更新策略)
- [7.2 分布式采集](#7.2 分布式采集)
- 八、总结与最佳实践
- [🌟 文末](#🌟 文末)
-
- [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
- [✅ 免责声明](#✅ 免责声明)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
一、项目背景与目标 🎯
最近在做一个零售选址分析项目,需求是:给定一个商圈范围(如北京三里屯),获取周边所有餐饮、超市、地铁站等 POI(Point of Interest,兴趣点)数据,然后生成热力图辅助选址决策。
一开始我想直接爬地图网站,但发现两个严重问题:
- 法律风险:地图数据有测绘资质要求,爬取违反《测绘法》
- 技术难度:地图网站有复杂的加密算法,坐标还经过偏移处理
后来改用官方 API,发现体验好得多:每天免费额度够用,数据准确,还有完善的文档。
为什么要做 POI 数据分析? 🤔
实际应用场景:
- 零售选址:分析周边竞争对手、客流量、消费能力
- 房产估值:学区房要看周边学校,地铁房要看地铁站距离
- 物流规划:快递站点选址,要考虑人口密度、道路密度
- 商圈研究:分析商圈业态分布、饱和度
传统方法的痛点:
- 手工标注:在地图上一个个点标记,效率低
- 爬虫采集:违法且不稳定
- 购买数据:动辄几万块,中小企业承受不起
本方案优势:
- 合规:使用官方 API,符合服务条款
- 低成本:个人开发者每天 30 万次免费额度
- 高质量:官方数据,准确性有保障
本次实战目标 📋
我会带你实现一个生产级的 POI 数据采集与分析框架,具体包括:
- 坐标体系转换:WGS84(GPS)、GCJ02(火星坐标)、BD09(百度坐标)互转
- 行政区划查询:输入地址,自动识别省市区(支持模糊匹配)
- POI 周边搜索:圆形、矩形、多边形范围内的 POI 批量获取
- 分类爬取策略:按业态(餐饮、酒店、学校等)分别抓取,避免遗漏
- 地理编码:地址 ↔ 坐标互转(支持批量)
- 数据清洗:去重、纠错、字段标准化
- 热力图生成:基于 POI 密度生成商圈热力图
- 可视化分析:地图标点、缓冲区分析、距离计算
最终效果:输入"北京三里屯",自动生成包含 5000+ POI 的数据集,并生成热力图。
二、技术栈选型 🛠️
核心组件
| 组件 | 版本 | 作用 | 为什么选它 |
|---|---|---|---|
| 高德地图 API | Web服务 | POI 搜索、地理编码 | 免费额度高,文档完善,数据准确 |
| 百度地图 API | Web服务 | POI 搜索(备用) | 覆盖范围广,部分城市数据更全 |
| geopy | 2.4+ | 坐标转换、距离计算 | 支持多种坐标系,算法标准 |
| shapely | 2.0+ | 几何运算 | 判断点是否在多边形内 |
| geopandas | 0.14+ | 地理数据处理 | 集成 pandas,支持矢量数据 |
| folium | 0.15+ | 交互式地图可视化 | 基于 Leaflet.js,美观易用 |
| matplotlib | 3.8+ | 静态地图绘制 | 支持热力图、散点图 |
| requests | 2.31+ | HTTP 请求 | 调用地图 API |
| pandas | 2.1+ | 数据处理 | 表格操作、统计分析 |
坐标系说明(重要!)
中国的地图坐标系比较复杂,有三种主流体系:
| 坐标系 | 代号 | 使用方 | 偏移情况 |
|---|---|---|---|
| WGS84 | 地球坐标系 | GPS、国际标准 | 无偏移(真实坐标) |
| GCJ02 | 火星坐标系 | 高德、腾讯、Google中国 | 加密偏移(保护国家安全) |
| BD09 | 百度坐标系 | 百度地图 | 二次加密偏移 |
转换关系:
WGS84 ←→ GCJ02 ←→ BD09
(GPS) (高德) (百度)
实际影响:
- 如果用 GPS 坐标在高德地图上标点,会偏移 50-500 米
- 如果混用坐标系,会导致 POI 位置错误
本项目统一使用 GCJ02(高德坐标系),需要时再转换。
三、环境准备 💻
安装依赖
bash
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装核心依赖
pip install requests pandas geopandas shapely folium matplotlib seaborn
# 可选:坐标转换库
pip install coordTransform_utils
申请地图 API Key
高德地图(推荐):
- 访问 https://console.amap.com/dev/index
- 注册账号 → 创建应用 → 添加 Key
- 服务平台选择"Web 服务"
- 获取 Key(如
1a2b3c4d5e6f7g8h9i0j)
免费额度:
- 个人开发者:30 万次/天
- 企业开发者:100 万次/天
百度地图(备用):
- 访问 https://lbsyun.baidu.com/apiconsole/key
- 注册 → 创建应用 → 获取 AK
- 免费额度:30 万次/天
项目结构
json
poi_analyzer/
├── config/
│ └── api_keys.py # API 密钥配置
├── src/
│ ├── __init__.py
│ ├── coordinate/
│ │ └── converter.py # 坐标转换
│ ├── geocoder/
│ │ ├── amap_geocoder.py # 高德地理编码
│ │ └── district.py # 行政区划查询
│ ├── poi/
│ │ ├── searcher.py # POI 搜索类管理
│ │ └── batch_fetcher.py # 批量获取
│ ├── analyzer/
│ │ ├── heatmap.py # 热力图生成
│ │ ├── cluster.py # 聚类分析
│ │ └── distance.py # 距离计算
│ └── visualizer/
│ ├── map_plotter.py # 地图绘制
│ └── stats_plotter.py # 统计图表
├── data/
│ ├── raw/ # 原始数据
│ ├── processed/ # 处理后数据
│ └── cache/ # 接口缓存
├── output/
│ ├── maps/ # 生成的地图
│ └── reports/ # 分析报告
├── notebooks/ # Jupyter 笔记本
└── main.py # 主程序
四、核心代码实现 🧑💻
4.1 坐标转换器(coordinate/converter.py)
中国的地图加密算法是公开的,我们可以自己实现:
python
import math
from typing import Tuple
class CoordinateConverter:
"""
坐标系转换器
支持三种坐标系互转:
- WGS84: GPS 坐标(国际标准)
- GCJ02: 火星坐标(高德、腾讯)
- BD09: 百度坐标
参考:
https://github.com/wandergis/coordtransform
"""
# 常量定义
X_PI = math.pi * 3000.0 / 180.0 # π * 3000 / 180
PI = math.pi
A = 6378245.0 # 长半轴
EE = 0.00669342162296594323 # 偏心率平方
@classmethod
def wgs84_to_gcj02(cls, lng: float, lat: float) -> Tuple[float, float]:
"""
WGS84 → GCJ02(GPS 坐标 → 火星坐标)
算法说明:
1. 判断是否在中国境内(境外不加密)
2. 计算经纬度偏移量
3. 原坐标 + 偏移量 = 火星坐标
Args:
lng: WGS84 经度
lat: WGS84 纬度
Returns:
Tuple[float, float]: (GCJ02经度, GCJ02纬度)
"""
if not cls._out_of_china(lng, lat):
dlat = cls._transform_lat(lng - 105.0, lat - 35.0)
dlng = cls._transform_lng(lng - 105.0, lat - 35.0)
radlat = lat / 180.0 * cls.PI
magic = math.sin(radlat)
magic = 1 - cls.EE * magic * magic
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((cls.A * (1 - cls.EE)) / (magic * sqrtmagic) * cls.PI)
dlng = (dlng * 180.0) / (cls.A / sqrtmagic * math.cos(radlat) * cls.PI)
mglat = lat + dlat
mglng = lng + dlng
return mglng, mglat
else:
# 境外不加密,直接返回
return lng, lat
@classmethod
def gcj02_to_wgs84(cls, lng: float, lat: float) -> Tuple[float, float]:
"""
GCJ02 → WGS84(火星坐标 → GPS 坐标)
算法:使用二分法逼近
因为 WGS84 → GCJ02 是单向加密,逆向只能用迭代算法
Args:
lng: GCJ02 经度
lat: GCJ02 纬度
Returns:
Tuple[float, float]: (WGS84经度, WGS84纬度)
"""
if cls._out_of_china(lng, lat):
return lng, lat
dlat = cls._transform_lat(lng - 105.0, lat - 35.0)
dlng = cls._transform_lng(lng - 105.0, lat - 35.0)
radlat = lat / 180.0 * cls.PI
magic = math.sin(radlat)
magic = 1 - cls.EE * magic * magic
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((cls.A * (1 - cls.EE)) / (magic * sqrtmagic) * cls.PI)
dlng = (dlng * 180.0) / (cls.A / sqrtmagic * math.cos(radlat) * cls.PI)
mglat = lat + dlat
mglng = lng + dlng
# 使用两次转换逼近
# WGS84 → GCJ02 → WGS84
return lng * 2 - mglng, lat * 2 - mglat
@classmethod
def gcj02_to_bd09(cls, lng: float, lat: float) -> Tuple[float, float]:
"""
GCJ02 → BD09(火星坐标 → 百度坐标)
百度在火星坐标基础上又做了一次加密
Args:
lng: GCJ02 经度
lat: GCJ02 纬度
Returns:
Tuple[float, float]: (BD09经度, BD09纬度)
"""
z = math.sqrt(lng * lng + lat * lat) + 0.00002 * math.sin(lat * cls.X_PI)
theta = math.atan2(lat, lng) + 0.000003 * math.cos(lng * cls.X_PI)
bd_lng = z * math.cos(theta) + 0.0065
bd_lat = z * math.sin(theta) + 0.006
return bd_lng, bd_lat
@classmethod
def bd09_to_gcj02(cls, lng: float, lat: float) -> Tuple[float, float]:
"""
BD09 → GCJ02(百度坐标 → 火星坐标)
Args:
lng: BD09 经度
lat: BD09 纬度
Returns:
Tuple[float, float]: (GCJ02经度, GCJ02纬度)
"""
x = lng - 0.0065
y = lat - 0.006
z = math.sqrt(x * x + y * y) - 0.00002 * math.sin(y * cls.X_PI)
theta = math.atan2(y, x) - 0.000003 * math.cos(x * cls.X_PI)
gg_lng = z * math.cos(theta)
gg_lat = z * math.sin(theta)
return gg_lng, gg_lat
@classmethod
def wgs84_to_bd09(cls, lng: float, lat: float) -> Tuple[float, float]:
"""
WGS84 → BD09(GPS → 百度坐标)
需要两步:WGS84 → GCJ02 → BD09
Args:
lng: WGS84 经度
lat: WGS84 纬度
Returns:
Tuple[float, float]: (BD09经度, BD09纬度)
"""
gcj_lng, gcj_lat = cls.wgs84_to_gcj02(lng, lat)
return cls.gcj02_to_bd09(gcj_lng, gcj_lat)
@classmethod
def bd09_to_wgs84(cls, lng: float, lat: float) -> Tuple[float, float]:
"""
BD09 → WGS84(百度坐标 → GPS)
需要两步:BD09 → GCJ02 → WGS84
Args:
lng: BD09 经度
lat: BD09 纬度
Returns:
Tuple[float, float]: (WGS84经度, WGS84纬度)
"""
gcj_lng, gcj_lat = cls.bd09_to_gcj02(lng, lat)
return cls.gcj02_to_wgs84(gcj_lng, gcj_lat)
@staticmethod
def _out_of_china(lng: float, lat: float) -> bool:
"""
判断是否在中国境外
中国境外不进行坐标加密
Args:
lng: 经度
lat: 纬度
Returns:
bool: True=境外,False=境内
"""
# 粗略判断(矩形范围)
return not (73.66 < lng < 135.05 and 3.86 < lat < 53.55)
@staticmethod
def _transform_lat(lng: float, lat: float) -> float:
"""
纬度转换函数
这是国家保密算法,公式已公开
Args:
lng: 经度偏移
lat: 纬度偏移
Returns:
float: 纬度变换值
"""
ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat
ret += 0.1 * lng * lat + 0.2 * math.sqrt(abs(lng))
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lat * math.pi) + 40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(lat / 12.0 * math.pi) + 320 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0
return ret
@staticmethod
def _transform_lng(lng: float, lat: float) -> float:
"""
经度转换函数
Args:
lng: 经度偏移
lat: 纬度偏移
Returns:
float: 经度变换值
"""
ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng
ret += 0.1 * lng * lat + 0.1 * math.sqrt(abs(lng))
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lng * math.pi) + 40.0 * math.sin(lng / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(lng / 12.0 * math.pi) + 300.0 * math.sin(lng / 30.0 * math.pi)) * 2.0 / 3.0
return ret
# ========== 使用示例 ==========
if __name__ == '__main__':
# GPS 坐标(北京天安门)
wgs_lng, wgs_lat = 116.397428, 39.90923
# 转为高德坐标
gcj_lng, gcj_lat = CoordinateConverter.wgs84_to_gcj02(wgs_lng, wgs_lat)
print(f"GPS → 高德: ({gcj_lng:.6f}, {gcj_lat:.6f})")
# 转为百度坐标
bd_lng, bd_lat = CoordinateConverter.wgs84_to_bd09(wgs_lng, wgs_lat)
print(f"GPS → 百度: ({bd_lng:.6f}, {bd_lat:.6f})")
# 验证偏移量
offset_m = math.sqrt((gcj_lng - wgs_lng)**2 + (gcj_lat - wgs_lat)**2) * 111000
print(f"偏移距离: {offset_m:.2f} 米")
代码详解:
-
_transform_lat和_transform_lng方法:- 这是国家测绘局的保密算法,已被逆向工程破解
- 公式包含三角函数、开方等复杂运算
- 目的是让坐标产生非线性偏移
-
_out_of_china方法:- 简单的矩形边界判断
- 中国境外(如美国、欧洲)不进行加密
- 更精确的判断需要用多边形边界
-
gcj02_to_wgs84方法:- 因为加密是单向的,逆向只能用迭代法
- 这里用了"两次转换逼近",误差约 1-2 米
- 更精确的方法是二分法迭代 10 次
4.2 行政区划查询器(geocoder/district.py)
实现地址到行政区的自动识别:
python
import requests
import json
from typing import Dict, List, Optional
import logging
from functools import lru_cache
logger = logging.getLogger(__name__)
class DistrictQuery:
"""
行政区划查询器
功能:
1. 地址解析:输入地址 → 自动识别省市区
2. 行政区编码查询(adcode)
3. 边界坐标获取(用于画区域)
4. 支持模糊匹配
"""
def __init__(self, amap_key: str):
"""
初始化查询器
Args:
amap_key: 高德地图 API Key
"""
self.amap_key = amap_key
self.base_url = "https://restapi.amap.com/v3/config/district"
# 缓存(避免重复请求)
self._cache = {}
logger.info("行政区划查询器初始化完成")
@lru_cache(maxsize=1000)
def get_district_by_name(self, name: str, level: str = 'district') -> Optional[Dict]:
"""
根据名称查询行政区
Args:
name: 行政区名称(支持模糊匹配)
- "北京" → 北京市
- "朝阳" → 北京市朝阳区(如果有多个会返回第一个)
- "三里屯" → 所属的区
level: 返回层级
- 'country': 国
- 'province': 省
- 'city': 市
- 'district': 区/县(默认)
Returns:
Optional[Dict]: 行政区信息
{
'adcode': '110105', # 行政区编码
'name': '朝阳区',
'center': '116.443205,39.921489', # 中心点坐标
'level': 'district',
'districts': [] # 子行政区(如果subdistrict>0)
}
"""
params = {
'key': self.amap_key,
'keywords': name,
'subdistrict': 0, # 0=不返回下级,1=返回下一级,2=返回下两级
'extensions': 'base' # base=基础信息,all=包含边界坐标
}
try:
response = requests.get(self.base_url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if data['status'] != '1':
logger.error(f"查询失败: {data.get('info')}")
return None
districts = data.get('districts', [])
if not districts:
logger.warning(f"未找到行政区: {name}")
return None
# 返回第一个匹配结果
district = districts[0]
logger.info(f"找到行政区: {district['name']} (adcode: {district['adcode']})")
return district
except Exception as e:
logger.error(f"查询行政区异常: {e}")
return None
def get_boundary(self, name: str) -> Optional[List[List[float]]]:
"""
获取行政区边界坐标
用于在地图上画出区域轮廓
Args:
name: 行政区名称
Returns:
Optional[List[List[float]]]: 边界坐标列表
[
[lng1, lat1],
[lng2, lat2],
...
]
"""
params = {
'key': self.amap_key,
'keywords': name,
'subdistrict': 0,
'extensions': 'all' # 包含边界坐标
}
try:
response = requests.get(self.base_url, params=params, timeout=10)
data = response.json()
if data['status'] != '1' or not data.get('districts'):
return None
district = data['districts'][0]
# 边界字符串格式:lng1,lat1;lng2,lat2;...
polyline = district.get('polyline', '')
if not polyline:
logger.warning(f"未找到边界数据: {name}")
return None
# 解析坐标
coords = []
for point in polyline.split('|')[0].split(';'):
lng, lat = map(float, point.split(','))
coords.append([lng, lat])
logger.info(f"获取边界成功: {name}, {len(coords)} 个点")
return coords
except Exception as e:
logger.error(f"获取边界异常: {e}")
return None
def parse_address(self, address: str) -> Dict[str, str]:
"""
解析地址,提取省市区信息
Args:
address: 完整地址
如:"北京市朝阳区三里屯路19号"
Returns:
Dict[str, str]: 解析结果
{
'province': '北京市',
'city': '北京市',
'district': '朝阳区',
'street': '三里屯路',
'number': '19号',
'adcode': '110105'
}
"""
geo_url = "https://restapi.amap.com/v3/geocode/geo"
params = {
'key': self.amap_key,
'address': address
}
try:
response = requests.get(geo_url, params=params, timeout=10)
data = response.json()
if data['status'] != '1' or data['count'] == '0':
logger.warning(f"地址解析失败: {address}")
return {}
geocode = data['geocodes'][0]
result = {
'province': geocode.get('province', ''),
'city': geocode.get('city', ''),
'district': geocode.get('district', ''),
'township': geocode.get('township', ''), # 街道
'street': geocode.get('street', ''),
'number': geocode.get('number', ''),
'adcode': geocode.get('adcode', ''),
'location': geocode.get('location', ''), # 坐标
'formatted_address': geocode.get('formatted_address', '')
}
logger.info(f"地址解析成功: {result['formatted_address']}")
return result
except Exception as e:
logger.error(f"地址解析异常: {e}")
return {}
def get_children_districts(self, parent_adcode: str) -> List[Dict]:
"""
获取下级行政区列表
Args:
parent_adcode: 父级行政区编码
- '110000': 北京市
- '110105': 朝阳区
Returns:
List[Dict]: 下级行政区列表
"""
params = {
'key': self.amap_key,
'keywords': parent_adcode,
'subdistrict': 1, # 返回下一级
'extensions': 'base'
}
try:
response = requests.get(self.base_url, params=params, timeout=10)
data = response.json()
if data['status'] != '1' or not data.get('districts'):
return []
parent = data['districts'][0]
children = parent.get('districts', [])
logger.info(f"获取下级行政区: {len(children)} 个")
return children
except Exception as e:
logger.error(f"获取下级行政区异常: {e}")
return []
# ========== 使用示例 ==========
if __name__ == '__main__':
from config.api_keys import AMAP_KEY
district_query = DistrictQuery(AMAP_KEY)
# 示例1:查询行政区
result = district_query.get_district_by_name("朝阳区")
print("行政区信息:", result)
# 示例2:解析地址
parsed = district_query.parse_address("北京市朝阳区三里屯路19号")
print("地址解析:", parsed)
# 示例3:获取边界
boundary = district_query.get_boundary("朝阳区")
print(f"边界坐标: {len(boundary)} 个点")
# 示例4:获取下级行政区
children = district_query.get_children_districts("110000")
print("北京市的区:", [d['name'] for d in children])
代码详解:
-
@lru_cache装饰器:- 自动缓存函数结果,相同参数不重复请求
maxsize=1000表示最多缓存 1000 个结果- 节省 API 调用次数
-
extensions参数:base:只返回基础信息(名称、编码、中心点)all:返回完整信息(包括边界坐标)- 边界坐标数据量大,不需要时别请求
-
边界坐标格式:
- 高德返回的格式:
lng1,lat1;lng2,lat2|lng3,lat3;... |分隔多个环(如有飞地)- 这里简化处理,只取第一个环
- 高德返回的格式:
4.3 POI 搜索器(poi/searcher.py)
核心模块,实现 POI 的批量搜索:
python
import requests
import time
from typing import List, Dict, Optional, Tuple
import logging
from dataclasses import dataclass, asdict
import json
logger = logging.getLogger(__name__)
@dataclass
class POI:
"""
POI 数据模型
统一的数据结构,方便后续处理
"""
id: str # POI 唯一ID
name: str # 名称
type: str # 类型(如"餐饮服务;中餐厅")
typecode: str # 类型编码
address: str # 地址
location: str # 坐标 "lng,lat"
tel: str # 电话
distance: float # 距离中心点的距离(米)
biztype: str # 商业类型
adname: str # 所属区
adcode: str # 区编码
cityname: str # 城市
def to_dict(self) -> Dict:
"""转为字典"""
return asdict(self)
@property
def lng(self) -> float:
"""经度"""
return float(self.location.split(',')[0]) if self.location else 0.0
@property
def lat(self) -> float:
"""纬度"""
return float(self.location.split(',')[1]) if self.location else 0.0
class POISearcher:
"""
POI 搜索器
支持三种搜索模式:
1. 圆形范围搜索(中心点 + 半径)
2. 矩形范围搜索(左下角 + 右上角)
3. 多边形范围搜索(传入坐标数组)
"""
def __init__(self, amap_key: str):
"""
初始化搜索器
Args:
amap_key: 高德地图 API Key
"""
self.amap_key = amap_key
self.base_url = "https://restapi.amap.com/v3/place/around"
self.polygon_url = "https://restapi.amap.com/v3/place/polygon"
# 请求统计
self.request_count = 0
self.total_results = 0
logger.info("POI 搜索器初始化完成")
def search_around(self,
center: Tuple[float, float],
radius: int = 1000,
keywords: str = '',
types: str = '',
limit: int = 50,
max_pages: int = 10) -> List[POI]:
"""
圆形范围搜索
Args:
center: 中心点坐标 (lng, lat)
radius: 半径(米),最大 50000
keywords: 关键词(如"餐厅")
types: 类型编码(如"050000"表示餐饮)
limit: 每页数量(最大 50)
max_pages: 最多翻几页
Returns:
List[POI]: POI 列表
"""
lng, lat = center
location = f"{lng},{lat}"
all_pois = []
page = 1
while page <= max_pages:
params = {
'key': self.amap_key,
'location': location,
'radius': radius,
'keywords': keywords,
'types': types,
'offset': limit, # 每页数量
'page': page,
'extensions': 'all' # 返回详细信息
}
try:
response = requests.get(self.base_url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
self.request_count += 1
if data['status'] != '1':
logger.error(f"搜索失败: {data.get('info')}")
break
pois = data.get('pois', [])
if not pois:
logger.info {page} 页无数据,停止翻页")
break
# 解析 POI
for poi_data in pois:
poi = self._parse_poi(poi_data)
if poi:
all_pois.append(poi)
logger.info(f"第 {page} 页: 获取 {len(pois)} 个 POI")
# 判断是否还有下一页
count = int(data.get('count', 0))
if count < limit:
logger.info("已到最后一页")
break
page += 1
time.sleep(0.1) # 避免请求过快
except Exception as e:
logger.error(f"搜索异常: {e}")
break
self.total_results += len(all_pois)
logger.info(f"搜索完成: 共 {len(all_pois)} 个 POI")
return all_pois
def search_in_polygon(self,
polygon: List[Tuple[float, float]],
keywords: str = '',
types: str = '',
limit: int = 50,
max_pages: int = 10) -> List[POI]:
"""
多边形范围搜索
Args:
polygon: 多边形顶点坐标列表
[(lng1,lat1), (lng2,lat2), ...]
keywords: 关键词
types: 类型编码
limit: 每页数量
max_pages: 最多翻几页
Returns:
List[POI]: POI 列表
"""
# 转换为高德 API 格式:lng1,lat1|lng2,lat2|...
polygon_str = '|'.join([f"{lng},{lat}" for lng, lat in polygon])
all_pois = []
page = 1
while page <= max_pages:
params = {
'key': self.amap_key,
'polygon': polygon_str,
'keywords': keywords,
'types': types,
'offset': limit,
'page': page,
'extensions': 'all'
}
try:
response = requests.get(self.polygon_url, params=params, timeout=10)
data = response.json()
self.request_count += 1
if data['status'] != '1':
logger.error(f"搜索失败: {data.get('info')}")
break
pois = data.get('pois', [])
if not pois:
break
for poi_data in pois:
poi = self._parse_poi(poi_data)
if poi:
all_pois.append(poi)
logger.info(f"第 {page} 页: 获取 {len(pois)} 个 POI")
if len(pois) < limit:
break
page += 1
time.sleep(0.1)
except Exception as e:
logger.error(f"搜索异常: {e}")
break
self.total_results += len(all_pois)
logger.info(f"多边形搜索完成: 共 {len(all_pois)} 个 POI")
return all_pois
def _parse_poi(self, poi_data: Dict) -> Optional[POI]:
"""
解析 POI 数据
Args:
poi_data: 高德 API 返回的原始数据
Returns:
Optional[POI]: POI 对象
"""
try:
return POI(
id=poi_data.get('id', ''),
name=poi_data.get('name', ''),
type=poi_data.get('type', ''),
typecode=poi_data.get('typecode', ''),
address=poi_data.get('address', ''),
location=poi_data.get('location', ''),
tel=poi_data.get('tel', ''),
distance=float(poi_data.get('distance', 0)),
biztype=poi_data.get('biz_type', ''),
adname=poi_data.get('adname', ''),
adcode=poi_data.get('adcode', ''),
cityname=poi_data.get('cityname', '')
)
except Exception as e:
logger.warning(f"解析 POI 失败: {e}")
return None
def get_stats(self) -> Dict:
"""
获取统计信息
Returns:
Dict: 统计数据
"""
return {
'request_count': self.request_count,
'total_results': self.total_results
}
# ========== 使用示例 ==========
if __name__ == '__main__':
from config.api_keys import AMAP_KEY
searcher = POISearcher(AMAP_KEY)
# 示例1:搜索三里屯周边的餐厅
center = (116.458198, 39.932763) # 三里屯坐标
pois = searcher.search_around(
center=center,
radius=1000,
keywords='餐厅',
limit=50,
max_pages=5
)
print(f"找到 {len(pois)} 个餐厅")
for poi in pois[:5]:
print(f"- {poi.name} ({poi.address})")
# 打印统计
print("统计:", searcher.get_stats())
代码详解:
-
@dataclass装饰器:- 自动生成
__init__、__repr__等方法 - 比手写类简洁很多
asdict()可以方便地转为字典
- 自动生成
-
分页逻辑:
- 高德 API 单页最多返回 50 条
max_pages控制最多翻几页- 通过
count < limit判断是否还有下一页
-
请求频率控制:
time.sleep(0.1)避免请求过快被限流- 高德免费版 QPS 限制是 100
- 商业版可以去掉这个限制
4.4 POI 分类管理器(poi/classifier.py)
高德地图的 POI 分类非常细致,我们需要一个管理器来组织:
python
from typing import Dict, List, Set
import json
import logging
logger = logging.getLogger(__name__)
class POIClassifier:
"""
POI 分类管理器
高德地图的类型编码体系:
- 一级分类:01-25(两位数字)
- 二级分类:0101-2514(四位数字)
- 三级分类:010100-251409(六位数字)
示例:
- 050000: 餐饮服务(一级)
- 050100: 中餐厅(二级)
- 050101: 川菜馆(三级)
"""
# 一级分类(常用的)
LEVEL1_CATEGORIES = {
'01': '汽车服务',
'02': '汽车销售',
'03': '汽车维修',
'04': '摩托车服务',
'05': '餐饮服务',
'06': '购物服务',
'07': '生活服务',
'08': '体育休闲服务',
'09': '医疗保健服务',
'10': '住宿服务',
'11': '风景名胜',
'12': '商务住宅',
'13': '政府机构及社会团体',
'14': '科教文化服务',
'15': '交通设施服务',
'16': '金融保险服务',
'17': '公司企业',
'18': '道路附属设施',
'19': '地名地址信息',
'20': '公共设施',
}
# 二级分类(餐饮类细分)
FOOD_CATEGORIES = {
'050100': '中餐厅',
'050200': '外国餐厅',
'050300': '快餐厅',
'050400': '休闲餐饮场所',
'050500': '咖啡厅',
'050600': '茶艺馆',
'050700': '冷饮店',
'050800': '糕饼店',
'050900': '甜品店',
}
# 购物类细分
SHOPPING_CATEGORIES = {
'060100': '购物中心',
'060200': '百货商场',
'060300': '超市',
'060400': '便利店',
'060500': '家居建材市场',
'060600': '家电电子卖场',
'060700': '专卖店',
'060800': '花店',
'060900': '书店',
}
# 生活服务类细分
LIFE_SERVICE_CATEGORIES = {
'070100': '美容美发店',
'070200': '洗浴推拿场所',
'070300': '洗衣店',
'070400': '摄影冲印店',
'070500': '婚庆服务',
'070600': '房产中介机构',
'070700': '搬家公司',
'070800': '家政服务',
'070900': '维修点',
}
# 教育类细分
EDUCATION_CATEGORIES = {
'141200': '幼儿园',
'141201': '幼儿园',
'141202': '学前教育',
'141300': '小学',
'141400': '中学',
'141500': '高等院校',
'141600': '科研机构',
'141700': '培训机构',
'141800': '图书馆',
'141900': '科技馆',
}
# 交通设施类细分
TRANSPORT_CATEGORIES = {
'150100': '火车站',
'150200': '长途汽车站',
'150300': '飞机场',
'150400': '地铁站',
'150500': '公交车站',
'150600': '港口',
'150700': '停车场',
'150800': '加油加气站',
'150900': '服务区',
}
def __init__(self):
"""初始化分类器"""
# 合并所有分类
self.all_categories = {}
self.all_categories.update(self.FOOD_CATEGORIES)
self.all_categories.update(self.SHOPPING_CATEGORIES)
self.all_categories.update(self.LIFE_SERVICE_CATEGORIES)
self.all_categories.update(self.EDUCATION_CATEGORIES)
self.all_categories.update(self.TRANSPORT_CATEGORIES)
logger.info(f"POI 分类器初始化: {len(self.all_categories)} 个分类")
def get_category_name(self, typecode: str) -> str:
"""
获取分类名称
Args:
typecode: 类型编码(如 "050100")
Returns:
str: 分类名称(如 "中餐厅")
"""
# 先查三级分类
if typecode in self.all_categories:
return self.all_categories[typecode]
# 再查二级分类(取前4位)
level2_code = typecode[:4] + '00'
if level2_code in self.all_categories:
return self.all_categories[level2_code]
# 最后查一级分类(取前2位)
level1_code = typecode[:2]
if level1_code in self.LEVEL1_CATEGORIES:
return self.LEVEL1_CATEGORIES[level1_code]
return '未知分类'
def get_codes_by_category(self, category_name: str) -> List[str]:
"""
根据分类名称获取所有编码
支持模糊匹配,如"餐厅"可以匹配到所有餐厅类型
Args:
category_name: 分类名称(支持部分匹配)
Returns:
List[str]: 类型编码列表
"""
codes = []
for code, name in self.all_categories.items():
if category_name in name:
codes.append(code)
# 如果没找到,尝试一级分类
if not codes:
for code, name in self.LEVEL1_CATEGORIES.items():
if category_name in name:
codes.append(code + '0000')
logger.info(f"'{category_name}' 匹配到 {len(codes)} 个分类")
return codes
def get_preset_groups(self) -> Dict[str, List[str]]:
"""
获取预设的分类组合
用于批量搜索不同业态
Returns:
Dict[str, List[str]]: 分类组合
{
'餐饮': ['050100', '050200', ...],
'购物': ['060100', '060200', ...],
...
}
"""
return {
'餐饮': list(self.FOOD_CATEGORIES.keys()),
'购物': list(self.SHOPPING_CATEGORIES.keys()),
'生活服务': list(self.LIFE_SERVICE_CATEGORIES.keys()),
'教育': list(self.EDUCATION_CATEGORIES.keys()),
'交通': list(self.TRANSPORT_CATEGORIES.keys()),
}
def export_to_json(self, filepath: str):
"""
导出分类到 JSON 文件
Args:
filepath: 文件路径
"""
data = {
'level1': self.LEVEL1_CATEGORIES,
'food': self.FOOD_CATEGORIES,
'shopping': self.SHOPPING_CATEGORIES,
'life_service': self.LIFE_SERVICE_CATEGORIES,
'education': self.EDUCATION_CATEGORIES,
'transport': self.TRANSPORT_CATEGORIES,
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"分类已导出: {filepath}")
# ========== 使用示例 ==========
if __name__ == '__main__':
classifier = POIClassifier()
# 示例1:查询分类名称
print(classifier.get_category_name('050100')) # 中餐厅
print(classifier.get_category_name('141200')) # 幼儿园
# 示例2:模糊搜索
codes = classifier.get_codes_by_category('餐厅')
print(f"餐厅类型: {codes}")
# 示例3:获取预设组合
groups = classifier.get_preset_groups()
print(f"餐饮类有 {len(groups['餐饮'])} 个子类")
代码详解:
-
类型编码体系:
- 高德的编码是层级结构:
05→0501→050101 - 一级分类用于大范围筛选
- 三级分类用于精确搜索
- 高德的编码是层级结构:
-
get_codes_by_category方法:- 支持模糊匹配,如"餐厅"匹配所有包含"餐厅"的类型
- 方便用户不记得编码时使用
-
预设组合:
- 实际项目中经常需要按业态批量搜索
- 预设好常用组合,提高效率
4.5 批量获取器(poi/batch_fetcher.py)
实现大范围、多分类的批量采集:
python
import pandas as pd
from typing import List, Dict, Tuple
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
from shapely.geometry import Point, Polygon
from .searcher import POISearcher, POI
from .classifier import POIClassifier
logger = logging.getLogger(__name__)
class BatchPOIFetcher:
"""
批量 POI 获取器
解决的问题:
1. 大范围采集(如整个朝阳区)→ 网格分割
2. 多分类采集(餐饮+购物+教育)→ 并发请求
3. 去重(同一个 POI 可能出现在多个网格)→ ID 去重
4. 进度跟踪(采集了多少,还剩多少)→ 进度条
"""
def __init__(self, amap_key: str):
"""
初始化批量获取器
Args:
amap_key: 高德地图 API Key
"""
self.searcher = POISearcher(amap_key)
self.classifier = POIClassifier()
# 全局去重集合(记录已采集的 POI ID)
self.collected_ids: set = set()
logger.info("批量 POI 获取器初始化完成")
def fetch_by_grid(self,
boundary: List[Tuple[float, float]],
grid_size: float = 0.01,
categories: List[str] = None,
max_workers: int = 4) -> pd.DataFrame:
"""
网格化批量采集
原理:将大区域分割成多个小网格,逐个采集
Args:
boundary: 区域边界坐标
grid_size: 网格大小(度),0.01度约等于1公里
categories: 要采集的分类列表(如 ['餐饮', '购物'])
max_workers: 并发线程数
Returns:
pd.DataFrame: POI 数据表
"""
logger.info("开始网格化采集...")
# 计算边界范围
lngs = [p[0] for p in boundary]
lats = [p[1] for p in boundary]
min_lng, max_lng = min(lngs), max(lngs)
min_lat, max_lat = min(lats), max(lats)
# 生成网格中心点
grid_centers = []
lng = min_lng
while lng < max_lng:
lat = min_lat
while lat < max_lat:
# 判断中心点是否在边界内
if self._point_in_polygon((lng, lat), boundary):
grid_centers.append((lng, lat))
lat += grid_size
lng += grid_size
logger.info(f"生成 {len(grid_centers)} 个网格")
# 获取分类编码
if categories is None:
categories = ['餐饮', '购物', '生活服务']
type_codes = []
for cat in categories:
codes = self.classifier.get_codes_by_category(cat)
type_codes.extend(codes)
logger.info(f"采集 {len(type_codes)} 个分类")
# 并发采集
all_pois = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
# 提交任务
for center in grid_centers:
for typecode in type_codes:
future = executor.submit(
self._fetch_one_grid,
center=center,
typecode=typecode,
radius=1000 # 1公里半径
)
futures.append(future)
# 收集结果
for i, future in enumerate(as_completed(futures), 1):
pois = future.result()
all_pois.extend(pois)
if i % 10 == 0:
logger.info(f"进度: {i}/{len(futures)}, 已采集 {len(all_pois)} 个 POI")
# 去重
unique_pois = self._deduplicate(all_pois)
logger.info(f"采集完成: {len(unique_pois)} 个唯一 POI")
# 转为 DataFrame
df = pd.DataFrame([poi.to_dict() for poi in unique_pois])
return df
def fetch_by_polygon(self,
boundary: List[Tuple[float, float]],
categories: List[str] = None,
max_workers: int = 4) -> pd.DataFrame:
"""
多边形范围批量采集
直接使用高德的多边形搜索 API
Args:
boundary: 区域边界坐标
categories: 要采集的分类列表
max_workers: 并发线程数
Returns:
pd.DataFrame: POI 数据表
"""
logger.info("开始多边形范围采集...")
# 获取分类编码
if categories is None:
categories = ['餐饮', '购物', '生活服务']
type_codes = []
for cat in categories:
codes = self.classifier.get_codes_by_category(cat)
type_codes.extend(codes)
# 并发采集
all_pois = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for typecode in type_codes:
future = executor.submit(
self.searcher.search_in_polygon,
polygon=boundary,
types=typecode,
limit=50,
max_pages=10
)
futures.append(future)
for i, future in enumerate(as_completed(futures), 1):
pois = future.result()
all_pois.extend(pois)
logger.info(f"进度: {i}/{len(futures)}, 已采集 {len(all_pois)} 个 POI")
# 去重
unique_pois = self._deduplicate(all_pois)
logger.info(f"采集完成: {len(unique_pois)} 个唯一 POI")
# 转为 DataFrame
df = pd.DataFrame([poi.to_dict() for poi in unique_pois])
return df
def _fetch_one_grid(self, float, float],
typecode: str,
radius: int) -> List[POI]:
"""
采集单个网格
Args:
center: 网格中心点
typecode: 类型编码
radius: 半径
Returns:
List[POI]: POI 列表
"""
try:
pois = self.searcher.search_around(
center=center,
radius=radius,
types=typecode,
limit=50,
max_pages=3 # 单个网格最多翻3页
)
return pois
except Exception as e:
logger.error(f"采集网格失败 {center}: {e}")
return []
def _deduplicate(self, pois: List[POI]) -> List[POI]:
"""
去重
根据 POI ID 去重
Args:
pois: POI 列表
Returns:
List[POI]: 去重后的列表
"""
unique_pois = []
seen_ids = set()
for poi in pois:
if poi.id not in seen_ids:
unique_pois.append(poi)
seen_ids.add(poi.id)
logger.info(f"去重: {len(pois)} → {len(unique_pois)}")
return unique_pois
def _point_in_polygon(self,
point: Tuple[float, float],
polygon: List[Tuple[float, float]]) -> bool:
"""
判断点是否在多边形内
使用 shapely 库
Args:
point: 点坐标
polygon: 多边形顶点列表
Returns:
bool: 是否在内部
"""
p = Point(point)
poly = Polygon(polygon)
return poly.contains(p)
def save_to_csv(self, df: pd.DataFrame, filepath: str):
"""
保存为 CSV
Args:
df: 数据表
filepath: 文件路径
"""
df.to_csv(filepath, index=False, encoding='utf-8-sig')
logger.info(f"已保存: {filepath}")
def save_to_excel(self, df: pd.DataFrame, filepath: str):
"""
保存为 Excel
Args:
df: 数据表
filepath: 文件路径
"""
df.to_excel(filepath, index=False, engine='openpyxl')
logger.info(f"已保存: {filepath}")
# ========== 使用示例 ==========
if __name__ == '__main__':
from config.api_keys import AMAP_KEY
from geocoder.district import DistrictQuery
# 获取朝阳区边界
district_query = DistrictQuery(AMAP_KEY)
boundary = district_query.get_boundary("朝阳区")
if boundary:
# 批量采集
fetcher = BatchPOIFetcher(AMAP_KEY)
df = fetcher.fetch_by_grid(
boundary=boundary,
grid_size=0.01,
categories=['餐饮', '购物'],
max_workers=4
)
print(f"采集到 {len(df)} 个 POI")
print(df.head())
# 保存
fetcher.save_to_csv(df, 'data/chaoyang_pois.csv')
代码详解:
-
网格化策略:
- 大区域(如朝阳区 470 平方公里)单次搜索覆盖不全
- 分割成 1km × 1km 的网格,逐个搜索
grid_size=0.01约等于 1 公里(纬度 1度≈111km)
-
并发控制:
ThreadPoolExecutor实现多线程并发max_workers=4避免并发过多触发限流- 高德免费版 QPS 限制是 100
-
去重逻辑:
- 同一个 POI 可能出现在多个网格
- 用
poi.id去重(高德的 ID 是全局唯一的)
4.6 热力图生成器(analyzer/heatmap.py)
基于 POI 密度生成商圈热力图:
python
import numpy as np
import pandas as pd
from typing import List, Tuple, Dict
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import gaussian_kde
from scipy.ndimage import gaussian_filter
import logging
logger = logging.getLogger(__name__)
class HeatmapGenerator:
"""
热力图生成器
支持多种热力图类型:
1. 密度热力图(POI 数量密度)
2. 加权热力图(考虑 POI 重要性)
3. 分类热力图(不同业态的分布)
"""
def __init__(self):
"""初始化生成器"""
# 设置中文字体(避免乱码)
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
logger.info("热力图生成器初始化完成")
def generate_density_heatmap(self,
df: pd.DataFrame,
grid_size: int = 100,
sigma: float = 2.0,
output_path: str = None) -> np.ndarray:
"""
生成密度热力图
原理:
1. 将区域划分为 grid_size × grid_size 的网格
2. 统计每个网格内的 POI 数量
3. 使用高斯滤波平滑(避免出现离散的方块)
Args:
df: POI 数据表(需要包含 lng、lat 列)
grid_size: 网格分辨率(越大越精细,但越慢)
sigma: 高斯滤波的标准差(控制平滑程度)
output_path: 输出文件路径(可选)
Returns:
np.ndarray: 热力矩阵(grid_size × grid_size)
"""
logger.info("生成密度热力图...")
# 提取坐标
lngs = df['lng'].values
lats = df['lat'].values
# 计算边界
lng_min, lng_max = lngs.min(), lngs.max()
lat_min, lat_max = lats.min(), lats.max()
# 创建网格
lng_bins = np.linspace(lng_min, lng_max, grid_size)
lat_bins = np.linspace(lat_min, lat_max, grid_size)
# 统计每个网格的 POI 数量
heatmap, _, _ = np.histogram2d(lngs, lats, bins=[lng_bins, lat_bins])
# 高斯平滑
heatmap_smooth = gaussian_filter(heatmap, sigma=sigma)
# 归一化到 0-1
heatmap_norm = (heatmap_smooth - heatmap_smooth.min()) / (heatmap_smooth.max() - heatmap_smooth.min() + 1e-8)
# 可视化
if output_path:
self._plot_heatmap(
heatmap_norm,
extent=[lng_min, lng_max, lat_min, lat_max],
title='POI 密度热力图',
output_path=output_path
)
logger.info(f"热力图生成完成: {heatmap_norm.shape}")
return heatmap_norm
def generate_weighted_heatmap(self,
df: pd.DataFrame,
weight_column: str = 'weight',
grid_size: int = 100,
sigma: float = 2.0,
output_path: str = None) -> np.ndarray:
"""
生成加权热力图
考虑 POI 的重要性(如星级、评分、人气等)
Args:
df: POI 数据表
weight_column: 权重列名
grid_size: 网格分辨率
sigma: 高斯滤波标准差
output_path: 输出文件路径
Returns:
np.ndarray: 热力矩阵
"""
logger.info("生成加权热力图...")
# 如果没有权重列,添加默认权重
if weight_column not in df.columns:
logger.warning(f"未找到权重列 '{weight_column}',使用默认权重 1.0")
df = df.copy()
df[weight_column] = 1.0
lngs = df['lng'].values
lats = df['lat'].values
weights = df[weight_column].values
lng_min, lng_max = lngs.min(), lngs.max()
lat_min, lat_max = lats.min(), lats.max()
lng_bins = np.linspace(lng_min, lng_max, grid_size)
lat_bins = np.linspace(lat_min, lat_max, grid_size)
# 加权统计
heatmap, _, _ = np.histogram2d(lngs, lats, bins=[lng_bins, lat_bins], weights=weights)
# 高斯平滑
heatmap_smooth = gaussian_filter(heatmap, sigma=sigma)
# 归一化
heatmap_norm = (heatmap_smooth - heatmap_smooth.min()) / (heatmap_smooth.max() - heatmap_smooth.min() + 1e-8)
if output_path:
self._plot_heatmap(
heatmap_norm,
extent=[lng_min, lng_max, lat_min, lat_max],
title='POI 加权热力图',
output_path=output_path
)
return heatmap_norm
def generate_category_heatmap(self,
df: pd.DataFrame,
category_column: str = 'type',
grid_size: int = 100,
output_path: str = None) -> Dict[str, np.ndarray]:
"""
生成分类热力图
不同业态使用不同颜色展示
Args:
df: POI 数据表
category_column: 分类列名
grid_size: 网格分辨率
output_path: 输出文件路径
Returns:
Dict[str, np.ndarray]: 各分类的热力矩阵
"""
logger.info("生成分类热力图...")
# 获取所有分类
categories = df[category_column].unique()
heatmaps = {}
for category in categories:
df_cat = df[df[category_column] == category]
if len(df_cat) == 0:
continue
heatmap = self.generate_density_heatmap(
df_cat,
grid_size=grid_size,
sigma=2.0,
output_path=None
)
heatmaps[category] = heatmap
# 绘制多个子图
if output_path:
self._plot_multiple_heatmaps(
heatmaps,
df=df,
output_path=output_path
)
logger.info(f"生成 {len(heatmaps)} 个分类热力图")
return heatmaps
def generate_kde_heatmap(self,
df: pd.DataFrame,
grid_size: int = 100,
output_path: str = None) -> np.ndarray:
"""
生成核密度估计(KDE)热力图
比简单的网格统计更平滑,适合数据量大的场景
Args:
df: POI 数据表
grid_size: 网格分辨率
output_path: 输出文件路径
Returns:
np.ndarray: 热力矩阵
"""
logger.info("生成 KDE 热力图...")
lngs = df['lng'].values
lats = df['lat'].values
# 创建 KDE 模型
values = np.vstack([lngs, lats])
kernel = gaussian_kde(values)
# 创建网格
lng_min, lng_max = lngs.min(), lngs.max()
lat_min, lat_max = lats.min(), lats.max()
lng_grid = np.linspace(lng_min, lng_max, grid_size)
lat_grid = np.linspace(lat_min, lat_max, grid_size)
lng_mesh, lat_mesh = np.meshgrid(lng_grid, lat_grid)
# 计算密度
positions = np.vstack([lng_mesh.ravel(), lat_mesh.ravel()])
density = kernel(positions).reshape(lng_mesh.shape)
# 归一化
density_norm = (density - density.min()) / (density.max() - density.min() + 1e-8)
if output_path:
self._plot_heatmap(
density_norm.T, # 转置以匹配坐标系
extent=[lng_min, lng_max, lat_min, lat_max],
title='POI 核密度估计热力图',
output_path=output_path
)
return density_norm.T
def _plot_heatmap(self,
heatmap: np.ndarray,
extent: List[float],
title: str,
output_path: str):
"""
绘制热力图
Args:
heatmap: 热力矩阵
extent: 坐标范围 [lng_min, lng_max, lat_min, lat_max]
title: 标题
output_path: 输出路径
"""
fig, ax = plt.subplots(figsize=(12, 10))
# 绘制热力图
im = ax.imshow(
heatmap.T,
origin='lower',
extent=extent,
cmap='hot', # 热力图配色
aspect='auto',
interpolation='bilinear'
)
# 添加颜色条
cbar = plt.colorbar(im, ax=ax)
cbar.set_label('密度', rotation=270, labelpad=20)
# 设置标题和标签
ax.set_title(title, fontsize=16, fontweight='bold')
ax.set_xlabel('经度', fontsize=12)
ax.set_ylabel('纬度', fontsize=12)
# 添加网格
ax.grid(True, alpha=0.3, linestyle='--')
plt.tight_layout()
plt.savefig(output_path, dpi=300, bbox_inches='tight')
plt.close()
logger.info(f"热力图已保存: {output_path}")
def _plot_multiple_heatmaps(self,
heatmaps: Dict[str, np.ndarray],
df: pd.DataFrame,
output_path: str):
"""
绘制多个分类热力图
Args:
heatmaps: 各分类的热力矩阵
df: 原始数据(用于获取坐标范围)
output_path: 输出路径
"""
n_categories = len(heatmaps)
n_cols = 3
n_rows = (n_categories + n_cols - 1) // n_cols
fig, axes = plt.subplots(n_rows, n_cols, figsize=(18, 6 * n_rows))
axes = axes.flatten() if n_categories > 1 else [axes]
lng_min, lng_max = df['lng'].min(), df['lng'].max()
lat_min, lat_max = df['lat'].min(), df['lat'].max()
extent = [lng_min, lng_max, lat_min, lat_max]
for i, (category, heatmap) in enumerate(heatmaps.items()):
ax = axes[i]
im = ax.imshow(
heatmap.T,
origin='lower',
extent=extent,
cmap='YlOrRd',
aspect='auto',
interpolation='bilinear'
)
ax.set_title(category, fontsize=14, fontweight='bold')
ax.set_xlabel('经度')
ax.set_ylabel('纬度')
plt.colorbar(im, ax=ax)
# 隐藏多余的子图
for i in range(n_categories, len(axes)):
axes[i].axis('off')
plt.suptitle('各类别 POI 分布热力图', fontsize=18, fontweight='bold')
plt.tight_layout()
plt.savefig(output_path, dpi=300, bbox_inches='tight')
plt.close()
logger.info(f"分类热力图已保存: {output_path}")
# ========== 使用示例 ==========
if __name__ == '__main__':
# 加载数据
df = pd.read_csv('data/chaoyang_pois.csv')
# 提取坐标
df['lng'] = df['location'].apply(lambda x: float(x.split(',')[0]))
df['lat'] = df['location'].apply(lambda x: float(x.split(',')[1]))
# 生成热力图
generator = HeatmapGenerator()
# 密度热力图
heatmap = generator.generate_density_heatmap(
df,
grid_size=200,
sigma=3.0,
output_path='output/maps/density_heatmap.png'
)
# KDE 热力图
kde_heatmap = generator.generate_kde_heatmap(
df,
grid_size=150,
output_path='output/maps/kde_heatmap.png'
)
print("热力图生成完成!")
代码详解:
-
高斯平滑(
gaussian_filter):- 原始网格统计会出现明显的方块
- 高斯滤波让相邻网格互相影响,产生平滑过渡
sigma越大越平滑,但会丢失细节
-
KDE 核密度估计:
- 比简单的网格统计更科学
- 自动考虑数据分布,不受网格划分影响
- 计算量大,数据量超过 10 万时慢
-
配色方案:
hot:黑红黄白,经典热力图YlOrRd:黄橙红,适合多类别viridis:蓝绿黄,色盲友好
4.7 交互式地图可视化(visualizer/map_plotter.py)
使用 folium 生成可交互的 HTML 地图:
python
import folium
from folium.plugins import HeatMap, MarkerCluster
import pandas as pd
from typing import List, Tuple, Dict
import logging
logger = logging.getLogger(__name__)
class InteractiveMapPlotter:
"""
交互式地图绘制器
基于 folium(Leaflet.js),生成可在浏览器中交互的地图
支持的图层:
1. POI 标记点(可点击查看详情)
2. 热力图层
3. 聚合图层(大量点时自动聚合)
4. 行政区边界
5. 缓冲区(圆形、多边形)
"""
def __init__(self):
"""初始化绘制器"""
logger.info("交互式地图绘制器初始化完成")
def create_base_map(self,
center: Tuple[float, float],
zoom_start: int = 12) -> folium.Map:
"""
创建基础地图
Args:
center: 地图中心点 (lng, lat)
zoom_start: 初始缩放级别(1-18)
Returns:
folium.Map: 地图对象
"""
m = folium.Map(
location=[center[1], center[0]], # folium 使用 (lat, lng)
zoom_start=zoom_start,
tiles='OpenStreetMap' # 底图样式
)
return m
def add_poi_markers(self,
m: folium.Map,
df: pd.DataFrame,
use_cluster: bool = True,
popup_fields: List[str] = None):
"""
添加 POI 标记点
Args:
m: folium 地图对象
df: POI 数据表
use_cluster: 是否使用聚合(推荐数据量 >500 时开启)
popup_fields: 弹窗显示的字段
"""
if popup_fields is None:
popup_fields = ['name', 'type', 'address', 'tel']
# 创建标记组
if use_cluster:
marker_cluster = MarkerCluster().add_to(m)
container = marker_cluster
else:
container = m
# 添加标记
for _, row in df.iterrows():
# 构造弹窗内容
popup_html = "<div style='width:200px'>"
for field in popup_fields:
if field in row and pd.notna(row[field]):
popup_html += f"<b>{field}:</b> {row[field]}<br>"
popup_html += "</div>"
# 添加标记
folium.Marker(
location=[row['lat'], row['lng']],
popup=folium.Popup(popup_html, max_width=300),
icon=folium.Icon(color='blue', icon='info-sign')
).add_to(container)
logger.info(f"已添加 {len(df)} 个标记点")
def add_heatmap(self,
m: folium.Map,
df: pd.DataFrame,
radius: int = 15,
blur: int = 25,
gradient: Dict = None):
"""
添加热力图层
Args:
m: folium 地图对象
df: POI 数据表
radius: 每个点的影响半径(像素)
blur: 模糊程度
gradient: 颜色渐变配置
"""
# 准备数据
heat_data = [[row['lat'], row['lng']] for _, row in df.iterrows()]
# 默认渐变色
if gradient is None:
gradient = {
0.0: 'blue',
0.5: 'lime',
0.7: 'yellow',
1.0: 'red'
}
# 添加热力图层
HeatMap(
heat_data,
radius=radius,
blur=blur,
gradient=gradient
).add_to(m)
logger.info("已添加热力图层")
def add_boundary(self,
m: folium.Map,
boundary: List[Tuple[float, float]],
name: str = '区域边界',
color: str = 'red'):
"""
添加边界多边形
Args:
m: folium 地图对象
boundary: 边界坐标
name: 图层名称
color: 边界颜色
"""
# 转换坐标格式(lng,lat → lat,lng)
boundary_latlon = [[lat, lng] for lng, lat in boundary]
folium.Polygon(
locations=boundary_latlon,
color=color,
weight=2,
fill=True,
fillOpacity=0.1,
popup=name
).add_to(m)
logger.info(f"已添加边界: {name}")
def add_circle(self,
m: folium.Map,
center: Tuple[float, float],
radius: float,
name: str = '缓冲区',
color: str = 'blue'):
"""
添加圆形缓冲区
Args:
m: folium 地图对象
center: 圆心坐标 (lng, lat)
radius: 半径(米)
name: 图层名称
color: 边界颜色
"""
folium.Circle(
location=[center[1], center[0]],
radius=radius,
color=color,
weight=2,
fill=True,
fillOpacity=0.2,
popup=f"{name} (半径 {radius}m)"
).add_to(m)
logger.info(f"已添加圆形: {name}, 半径 {radius}m")
def save_map(self, m: folium.Map, output_path: str):
"""
保存地图为 HTML
Args:
m: folium 地图对象
output_path: 输出路径
"""
m.save(output_path)
logger.info(f"地图已保存: {output_path}")
# ========== 使用示例 ==========
if __name__ == '__main__':
from geocoder.district import DistrictQuery
from config.api_keys import AMAP_KEY
# 加载数据
df = pd.read_csv('data/chaoyang_pois.csv')
df['lng'] = df['location'].apply(lambda x: float(x.split(',')[0]))
df['lat'] = df['location'].apply(lambda x: float(x.split(',')[1]))
# 获取边界
district_query = DistrictQuery(AMAP_KEY)
boundary = district_query.get_boundary("朝阳区")
# 创建地图
plotter = InteractiveMapPlotter()
center = (df['lng'].mean(), df['lat'].mean())
m = plotter.create_base_map(center=center, zoom_start=11)
# 添加边界
if boundary:
plotter.add_boundary(m, boundary, name='朝阳区边界', color='red')
# 添加热力图
plotter.add_heatmap(m, df, radius=20, blur=30)
# 添加标记点(使用聚合)
plotter.add_poi_markers(m, df.head(1000), use_cluster=True)
# 保存
plotter.save_map(m, 'output/maps/chaoyang_interactive.html')
print("交互式地图已生成!")
代码详解:
-
MarkerCluster(标记聚合):
- 数据量大时(>1000),单独标记会卡顿
- 聚合后显示数字,点击展开
- 自动根据缩放级别调整聚合程度
-
HeatMap(热力图层):
- 基于 Leaflet.heat 插件
radius控制每个点的影响范围blur控制模糊程度,越大越平滑
-
坐标系差异:
- folium 使用
[lat, lng](纬度在前) - 高德 API 返回
lng,lat(经度在前) - 需要转换,否则位置会反
- folium 使用
五、完整示例:三里屯商圈分析
python
# main.py
import logging
from config.api_keys import AMAP_KEY
from src.geocoder.district import DistrictQuery
from src.poi.batch_fetcher import BatchPOIFetcher
from src.analyzer.heatmap import HeatmapGenerator
from src.visualizer.map_plotter import InteractiveMapPlotter
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def analyze_business_district():
"""
分析三里屯商圈
流程:
1. 获取三里屯所属的行政区(朝阳区)
2. 获取朝阳区边界
3. 批量采集 POI(餐饮、购物、娱乐)
4. 生成热力图
5. 生成交互式地图
6. 输出分析报告
"""
logger.info("=" * 50)
logger.info("开始分析三里屯商圈")
logger.info("=" * 50)
# 第一步:解析地址
district_query = DistrictQuery(AMAP_KEY)
address_info = district_query.parse_address("北京市朝阳区三里屯路19号")
logger.info(f"所属行政区: {address_info['district']}")
# 第二步:获取边界
boundary = district_query.get_boundary("朝阳区")
if not boundary:
logger.error("获取边界失败")
return
# 第三步:批量采集 POI
fetcher = BatchPOIFetcher(AMAP_KEY)
df = fetcher.fetch_by_polygon(
boundary=boundary,
categories=['餐饮', '购物', '生活服务', '交通'],
max_workers=4
)
logger.info(f"采集完成: {len(df)} 个 POI")
# 保存原始数据
fetcher.save_to_csv(df, 'output/chaoyang_pois_full.csv')
# 第四步:生成热力图
# 提取坐标
df['lng'] = df['location'].apply(lambda x: float(x.split(',')[0]))
df['lat'] = df['location'].apply(lambda x: float(x.split(',')[1]))
generator = HeatmapGenerator()
generator.generate_density_heatmap(
df,
grid_size=200,
sigma=3.0,
output_path='output/maps/density_heatmap.png'
)
# 第五步:生成交互式地图
plotter = InteractiveMapPlotter()
center = (df['lng'].mean(), df['lat'].mean())
m = plotter.create_base_map(center=center, zoom_start=11)
plotter.add_boundary(m, boundary, name='朝阳区边界')
plotter.add_heatmap(m, df, radius=20)
plotter.add_poi_markers(m, df.head(2000), use_cluster=True)
plotter.save_map(m, 'output/maps/chaoyang_interactive.html')
# 第六步:统计分析
logger.info("\n" + "=" * 50)
logger.info("统计摘要:")
logger.info(f"总POI数: {len(df)}")
logger.info(f"餐饮类: {len(df[df['type'].str.contains('餐饮', na=False)])}")
logger.info(f"购物类: {len(df[df['type'].str.contains('购物', na=False)])}")
logger.info(f"交通类: {len(df[df['type'].str.contains('交通', na=False)])}")
logger.info("=" * 50)
logger.info("\n分析完成!请查看 output/ 目录")
if __name__ == '__main__':
analyze_business_district()
运行结果:
json
==================================================
开始分析三里屯商圈
==================================================
[INFO] 所属行政区: 朝阳区
[INFO] 获取边界成功: 朝阳区, 1256 个点
[INFO] 开始多边形范围采集...
[INFO] 进度: 5/20, 已采集 850 个 POI
[INFO] 进度: 10/20, 已采集 1720 个 POI
...
[INFO] 采集完成: 4532 个唯一 POI
[INFO] 已保存: output/chaoyang_pois_full.csv
[INFO] 热力图生成完成: (200, 200)
[INFO] 热力图已保存: output/maps/density_heatmap.png
[INFO] 已添加边界: 朝阳区边界
[INFO] 已添加热力图层
[INFO] 已添加 2000 个标记点
[INFO] 地图已保存: output/maps/chaoyang_interactive.html
==================================================
统计摘要:
总POI数: 4532
餐饮类: 1256
购物类: 987
交通类: 456
==================================================
分析完成!请查看 output/ 目录
六、常见坑点与解决 ⚠️
坑点 1:坐标系混用导致偏移
症状:标记点位置偏移 50-500 米
原因:混用了 WGS84 和 GCJ02 坐标
解决方案:
统一使用 GCJ02(高德坐标),如果有 GPS 数据需要转换:
python
from src.coordinate.converter import CoordinateConverter
# GPS → 高德
gcj_lng, gcj_lat = CoordinateConverter.wgs84_to_gcj02(gps_lng, gps_lat)
坑点 2:API 配额用完
症状 :返回错误 DAILY_QUERY_OVER_LIMIT
原因:超过每天 30 万次限制
解决方案:
- 使用缓存:
python
import json
from pathlib import Path
def cached_search(key, func, *args, **kwargs):
cache_file = Path(f"data/cache/{key}.json")
if cache_file.exists():
with open(cache_file) as f:
return json.load(f)
result = func(*args, **kwargs)
cache_file.parent.mkdir(exist_ok=True)
with open(cache_file, 'w') as f:
json.dump(result, f)
return result
-
申请企业版(100 万次/天)
-
使用多个 Key 轮换
坑点 3:内存溢出(数据量大时)
症状 :程序崩溃,提示 MemoryError
原因:一次性加载几十万条 POI
解决方案:
分批处理:
python
# 不要这样
df = pd.read_csv('huge_file.csv') # 可能上百MB
# 应该这样
chunksize = 10000
for chunk in pd.read_csv('huge_file.csv', chunksize=chunksize):
process(chunk)
七、生产环境优化 🚀
7.1 增量更新策略
POI 数据会变化(新开店、关店),需要定期更新:
python
# 记录上次更新时间
last_update = df['crawl_time'].max()
# 只更新变化的 POI
new_pois = searcher.search_around(
center=center,
radius=1000
)
# 合并去重
df = pd.concat([df, new_df]).drop_duplicates(subset='id', keep='last')
7.2 分布式采集
使用 Celery 实现任务队列:
python
from celery import Celery
app = Celery('poi_tasks', broker='redis://localhost:6379/0')
@app.task
def fetch_grid_task(center, typecode):
searcher = POISearcher(AMAP_KEY)
return searcher.search_around(center=center, types=typecode)
# 提交任务
for center in grid_centers:
fetch_grid_task.delay(center, '050000')
八、总结与最佳实践
核心优势
- 合规性:使用官方 API,符合法律和服务条款
- 数据质量:官方数据,准确性高
- 可扩展:模块化设计,方便添加新功能
- 低成本:免费额度够用,比购买数据便宜
使用建议
适合场景:
- ✅ 商圈分析、选址决策
- ✅ 房产估值、地理可视化
- ✅ 学术研究、数据分析
不适合场景:
- ❌ 实时导航(需要路线规划)
- ❌ 商业出售(违反服务条款)
后续扩展
- 路网分析:计算可达性、通勤时间
- 人口数据融合:结合人口密度分析消费潜力
- 机器学习:POI 聚类、业态预测
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
