Python爬虫实战:地图 POI + 行政区反查实战 - 商圈热力数据准备完整方案(附CSV导出 + SQLite持久化存储)!

㊗️本期内容已收录至专栏《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,兴趣点)数据,然后生成热力图辅助选址决策

一开始我想直接爬地图网站,但发现两个严重问题:

  1. 法律风险:地图数据有测绘资质要求,爬取违反《测绘法》
  2. 技术难度:地图网站有复杂的加密算法,坐标还经过偏移处理

后来改用官方 API,发现体验好得多:每天免费额度够用,数据准确,还有完善的文档

为什么要做 POI 数据分析? 🤔

实际应用场景

  1. 零售选址:分析周边竞争对手、客流量、消费能力
  2. 房产估值:学区房要看周边学校,地铁房要看地铁站距离
  3. 物流规划:快递站点选址,要考虑人口密度、道路密度
  4. 商圈研究:分析商圈业态分布、饱和度

传统方法的痛点

  • 手工标注:在地图上一个个点标记,效率低
  • 爬虫采集:违法且不稳定
  • 购买数据:动辄几万块,中小企业承受不起

本方案优势

  • 合规:使用官方 API,符合服务条款
  • 低成本:个人开发者每天 30 万次免费额度
  • 高质量:官方数据,准确性有保障

本次实战目标 📋

我会带你实现一个生产级的 POI 数据采集与分析框架,具体包括:

  1. 坐标体系转换:WGS84(GPS)、GCJ02(火星坐标)、BD09(百度坐标)互转
  2. 行政区划查询:输入地址,自动识别省市区(支持模糊匹配)
  3. POI 周边搜索:圆形、矩形、多边形范围内的 POI 批量获取
  4. 分类爬取策略:按业态(餐饮、酒店、学校等)分别抓取,避免遗漏
  5. 地理编码:地址 ↔ 坐标互转(支持批量)
  6. 数据清洗:去重、纠错、字段标准化
  7. 热力图生成:基于 POI 密度生成商圈热力图
  8. 可视化分析:地图标点、缓冲区分析、距离计算

最终效果:输入"北京三里屯",自动生成包含 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

高德地图(推荐):

  1. 访问 https://console.amap.com/dev/index
  2. 注册账号 → 创建应用 → 添加 Key
  3. 服务平台选择"Web 服务"
  4. 获取 Key(如 1a2b3c4d5e6f7g8h9i0j

免费额度

  • 个人开发者:30 万次/天
  • 企业开发者:100 万次/天

百度地图(备用):

  1. 访问 https://lbsyun.baidu.com/apiconsole/key
  2. 注册 → 创建应用 → 获取 AK
  3. 免费额度: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} 米")

代码详解

  1. _transform_lat_transform_lng 方法

    • 这是国家测绘局的保密算法,已被逆向工程破解
    • 公式包含三角函数、开方等复杂运算
    • 目的是让坐标产生非线性偏移
  2. _out_of_china 方法

    • 简单的矩形边界判断
    • 中国境外(如美国、欧洲)不进行加密
    • 更精确的判断需要用多边形边界
  3. 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])

代码详解

  1. @lru_cache 装饰器

    • 自动缓存函数结果,相同参数不重复请求
    • maxsize=1000 表示最多缓存 1000 个结果
    • 节省 API 调用次数
  2. extensions 参数

    • base:只返回基础信息(名称、编码、中心点)
    • all:返回完整信息(包括边界坐标)
    • 边界坐标数据量大,不需要时别请求
  3. 边界坐标格式

    • 高德返回的格式: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())

代码详解

  1. @dataclass 装饰器

    • 自动生成 __init____repr__ 等方法
    • 比手写类简洁很多
    • asdict() 可以方便地转为字典
  2. 分页逻辑

    • 高德 API 单页最多返回 50 条
    • max_pages 控制最多翻几页
    • 通过 count < limit 判断是否还有下一页
  3. 请求频率控制

    • 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['餐饮'])} 个子类")

代码详解

  1. 类型编码体系

    • 高德的编码是层级结构:050501050101
    • 一级分类用于大范围筛选
    • 三级分类用于精确搜索
  2. get_codes_by_category 方法

    • 支持模糊匹配,如"餐厅"匹配所有包含"餐厅"的类型
    • 方便用户不记得编码时使用
  3. 预设组合

    • 实际项目中经常需要按业态批量搜索
    • 预设好常用组合,提高效率

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')

代码详解

  1. 网格化策略

    • 大区域(如朝阳区 470 平方公里)单次搜索覆盖不全
    • 分割成 1km × 1km 的网格,逐个搜索
    • grid_size=0.01 约等于 1 公里(纬度 1度≈111km)
  2. 并发控制

    • ThreadPoolExecutor 实现多线程并发
    • max_workers=4 避免并发过多触发限流
    • 高德免费版 QPS 限制是 100
  3. 去重逻辑

    • 同一个 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("热力图生成完成!")

代码详解

  1. 高斯平滑(gaussian_filter

    • 原始网格统计会出现明显的方块
    • 高斯滤波让相邻网格互相影响,产生平滑过渡
    • sigma 越大越平滑,但会丢失细节
  2. KDE 核密度估计

    • 比简单的网格统计更科学
    • 自动考虑数据分布,不受网格划分影响
    • 计算量大,数据量超过 10 万时慢
  3. 配色方案

    • 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("交互式地图已生成!")

代码详解

  1. MarkerCluster(标记聚合)

    • 数据量大时(>1000),单独标记会卡顿
    • 聚合后显示数字,点击展开
    • 自动根据缩放级别调整聚合程度
  2. HeatMap(热力图层)

    • 基于 Leaflet.heat 插件
    • radius 控制每个点的影响范围
    • blur 控制模糊程度,越大越平滑
  3. 坐标系差异

    • folium 使用 [lat, lng](纬度在前)
    • 高德 API 返回 lng,lat(经度在前)
    • 需要转换,否则位置会反

五、完整示例:三里屯商圈分析

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 万次限制

解决方案

  1. 使用缓存
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
  1. 申请企业版(100 万次/天)

  2. 使用多个 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')

八、总结与最佳实践

核心优势

  1. 合规性:使用官方 API,符合法律和服务条款
  2. 数据质量:官方数据,准确性高
  3. 可扩展:模块化设计,方便添加新功能
  4. 低成本:免费额度够用,比购买数据便宜

使用建议

适合场景

  • ✅ 商圈分析、选址决策
  • ✅ 房产估值、地理可视化
  • ✅ 学术研究、数据分析

不适合场景

  • ❌ 实时导航(需要路线规划)
  • ❌ 商业出售(违反服务条款)

后续扩展

  1. 路网分析:计算可达性、通勤时间
  2. 人口数据融合:结合人口密度分析消费潜力
  3. 机器学习:POI 聚类、业态预测

🌟 文末

好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

✅ 专栏持续更新中|建议收藏 + 订阅

墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?

评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


✅ 免责声明

本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

使用或者参考本项目即表示您已阅读并同意以下条款:

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
熊猫_豆豆8 小时前
YOLOP车道检测
人工智能·python·算法
nimadan128 小时前
**热门短剧小说扫榜工具2025推荐,精准捕捉爆款趋势与流量
人工智能·python
默默前行的虫虫8 小时前
MQTT.fx实际操作
python
YMWM_8 小时前
python3继承使用
开发语言·python
JMchen1238 小时前
AI编程与软件工程的学科融合:构建新一代智能驱动开发方法学
驱动开发·python·软件工程·ai编程
芷栀夏8 小时前
从 CANN 开源项目看现代爬虫架构的演进:轻量、智能与统一
人工智能·爬虫·架构·开源·cann
亓才孓9 小时前
[Class类的应用]反射的理解
开发语言·python
小镇敲码人9 小时前
深入剖析华为CANN框架下的Ops-CV仓库:从入门到实战指南
c++·python·华为·cann
摘星编程9 小时前
深入理解CANN ops-nn BatchNormalization算子:训练加速的关键技术
python