Python爬虫实战:使用高德地图开放平台API获取餐饮POI数据(店名、坐标、评分)数据采集与地理可视化(附CSV导出 + SQLite持久化存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [1️⃣ 标题 + 摘要](#1️⃣ 标题 + 摘要)
    • [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
    • [3️⃣ 合规与注意事项(必读)](#3️⃣ 合规与注意事项(必读))
    • [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
    • [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
    • [6️⃣ 核心实现:高德API调用模块](#6️⃣ 核心实现:高德API调用模块)
      • 设计要点
      • 完整代码实现
      • 代码详解
        • [1. 为什么要用Session?](#1. 为什么要用Session?)
        • [2. 重试机制的实现](#2. 重试机制的实现)
        • [3. 分页逻辑的处理](#3. 分页逻辑的处理)
        • [4. 经纬度的解析](#4. 经纬度的解析)
    • [7️⃣ 核心实现:网页补充爬取模块](#7️⃣ 核心实现:网页补充爬取模块)
      • 代码实现
      • 关键技术点解析
        • [1. URL编码的处理](#1. URL编码的处理)
        • [2. XPath选择器的容错](#2. XPath选择器的容错)
        • [3. 正则表达式提取数字](#3. 正则表达式提取数字)
    • [8️⃣ 核心实现:地理工具模块](#8️⃣ 核心实现:地理工具模块)
    • [9️⃣ 数据存储与管理](#9️⃣ 数据存储与管理)
      • 数据库设计
      • 数据清洗模块
      • 代码详解
        • [1. 电话号码清洗的正则表达式](#1. 电话号码清洗的正则表达式)
        • [2. 价格区间的处理](#2. 价格区间的处理)
        • [3. 位置去重的数学原理](#3. 位置去重的数学原理)
    • [🔟 地图可视化实现](#🔟 地图可视化实现)
    • [1️⃣1️⃣ 完整主程序实现](#1️⃣1️⃣ 完整主程序实现)
    • [1️⃣2️⃣ 运行方式与结果展示](#1️⃣2️⃣ 运行方式与结果展示)
      • 环境安装
      • 基础运行
      • 高级用法
      • 输出文件说明
        • [1. CSV文件 (`data/restaurants.csv`)](#1. CSV文件 (data/restaurants.csv))
        • [2. 交互式地图 (`output/restaurant_map.html`)](#2. 交互式地图 (output/restaurant_map.html))
        • [3. 数据库查询示例](#3. 数据库查询示例)
    • [1️⃣3️⃣ 常见问题与排错](#1️⃣3️⃣ 常见问题与排错)
      • [Q1: API返回 "INVALID_USER_KEY"](#Q1: API返回 "INVALID_USER_KEY")
      • [Q2: API返回数据为空(count=0)](#Q2: API返回数据为空(count=0))
      • [Q3: 网页爬取返回403或空页面](#Q3: 网页爬取返回403或空页面)
      • [Q4: XPath解析不到数据](#Q4: XPath解析不到数据)
      • [Q5: 坐标在地图上显示错误](#Q5: 坐标在地图上显示错误)
      • [Q6: 内存占用过高](#Q6: 内存占用过高)
    • [1️⃣4️⃣ 进阶优化](#1️⃣4️⃣ 进阶优化)
      • [1. 并发加速(异步版本)](#1. 并发加速(异步版本))
      • [2. 断点续跑(支持中断恢复)](#2. 断点续跑(支持中断恢复))
      • [3. 日志与监控](#3. 日志与监控)
      • [4. 定时任务(每日自动更新)](#4. 定时任务(每日自动更新))
    • [1️⃣5️⃣ 数据分析与应用场景](#1️⃣5️⃣ 数据分析与应用场景)
      • [1. 评分与价格关系分析](#1. 评分与价格关系分析)
      • [2. 区域餐饮饱和度分析](#2. 区域餐饮饱和度分析)
      • [3. 推荐菜品词云分析](#3. 推荐菜品词云分析)
    • [1️⃣6️⃣ 总结与延伸阅读](#1️⃣6️⃣ 总结与延伸阅读)
    • 附录:完整代码仓库结构
    • [🌟 文末](#🌟 文末)
      • [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈

💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 标题 + 摘要

一句话概括:使用高德地图开放平台API获取餐饮POI数据(店名、坐标、评分),结合网页爬取补充详细信息,最终生成结构化数据库和交互式地图。

你能获得:

  • 掌握地图API的正确调用方式与参数优化技巧
  • 学会经纬度坐标系转换与地理围栏计算
  • 构建餐饮数据采集、清洗、可视化的完整工程

2️⃣ 背景与需求(Why)

为什么要采集餐饮POI数据?

每次想找个餐厅,都要在各种App之间反复跳转对比:美团看评分、大众点评看推荐菜、高德看位置,效率极低。如果能把这些数据整合到一起,就能实现:

  • 选址分析:创业者可以分析某商圈的餐饮饱和度、竞争对手分布
  • 个性化推荐:基于位置和评分,生成"附近高分餐厅地图"
  • 价格监控:追踪同类餐厅的人均消费变化趋势
  • 数据可视化:制作城市美食热力图,发现"美食荒漠"区域

目标数据源与字段清单

主数据源 :高德地图POI搜索API(官方、合规、稳定)
辅助数据源:大众点评公开页面(补充详细评价和菜品)

API获取字段:

  • 店铺名称 (name)
  • 地址 (address)
  • 经纬度坐标 (longitude, latitude)
  • 类型标签 (type_code)
  • 电话 (tel)
  • 高德评分 (amap_rating)

网页补充字段:

  • 大众点评评分 (dianping_rating)
  • 评价数量 (review_count)
  • 人均消费 (avg_price)
  • 推荐菜品 (recommend_dishes)
  • 最新评价摘要 (recent_reviews)

3️⃣ 合规与注意事项(必读)

API使用规范

高德地图开放平台对个人开发者提供免费额度:

  • 每日调用量:30万次/天(个人认证用户)
  • 并发限制:不超过每秒200次
  • 使用场景:仅限非商业用途,不得用于竞品分析或转售数据

正确做法:

python 复制代码
# ✅ 合规:用于个人学习、数据分析
# ✅ 合规:在自己的App中展示地图数据
# ❌ 违规:批量采集后转售给第三方
# ❌ 违规:用于恶意竞争(如批量刷差评)

网页爬取注意事项

在补充爬取大众点评数据时:

  • 遵守robots.txt :检查 https://www.dianping.com/robots.txt
  • 控制频率:每个请求间隔2-5秒,避免触发反爬
  • 不绕过登录墙:只采集游客可见的公开内容
  • 不采集敏感信息:不抓取用户手机号、详细地址门牌号

数据使用边界

  • 可以做:整合数据生成个人美食地图
  • 可以做:分析餐饮行业趋势(论文/报告)
  • 不要做:未经授权发布到公开平台
  • 不要做:用于商业推广或广告投放

4️⃣ 技术选型与整体流程(What/How)

API vs 爬虫 vs 混合方案

方案 优势 劣势 适用场景
纯API 稳定、合规、速度快 字段有限,无详细评价 快速获取基础POI信息
纯爬虫 数据丰富,字段自定义 反爬严重,维护成本高 深度挖掘某平台数据
混合方案 兼顾稳定性和丰富度 代码复杂度略高 本次推荐

整体流程架构

json 复制代码
┌──────────────────┐
│ 输入:城市+关键词 │ (如"上海 火锅")
└────────┬─────────┘
         │
         ▼
┌─────────────────────┐
│ 高德API:POI搜索      │ → 获取基础信息(店名/坐标/电话)
│ - 处理分页(翻页)     │
│ - 坐标系转换(GCJ02)  │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ 数据清洗与去重       │ → 过滤无效数据、合并重复店铺
│ - 电话号码归一化     │
│ - 地址匹配           │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ 网页爬取:补充信息    │ → 大众点评详情页
│ - 构造搜索URL        │
│ - 解析评分/评价      │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ 存储到SQLite         │ → 结构化数据库
│ - 建立索引(经纬度)   │
│ - 导出CSV            │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ 地图可视化           │ → 生成HTML交互地图
│ - Folium热力图       │
│ - 标注评分最高的店   │
└─────────────────────┘

为什么选这套技术栈?

  • 高德API:国内最稳定的地图服务商,POI数据覆盖全面
  • requests + lxml:轻量级,适合补充爬取少量页面
  • Folium:基于Leaflet.js,生成美观的交互式地图
  • pandas:强大的数据清洗和分析能力
  • SQLite:无需安装数据库服务,适合中小规模数据

5️⃣ 环境准备与依赖安装(可复现)

Python版本要求

推荐 Python 3.9+ (最低3.8),需要用到字典合并运算符 | 和类型提示。

依赖安装

json 复制代码
pip install requests lxml pandas folium geopy

依赖说明:

  • requests: HTTP请求库(API调用和网页爬取)
  • lxml: 高性能HTML解析器
  • pandas: 数据处理和清洗
  • folium: 地图可视化库(生成HTML地图)
  • geopy: 地理计算工具(距离计算、坐标转换)

项目目录结构

json 复制代码
restaurant_poi_scraper/
│
├── config/
│   ├── __init__.py
│   └── settings.py          # 配置文件(API Key等)
│
├── scraper/
│   ├── __init__.py
│   ├── amap_api.py          # 高德API调用模块
│   ├── web_fetcher.py       # 网页爬取模块
│   ├── parser.py            # 数据解析模块
│   └── geo_utils.py         # 地理工具模块
│
├── storage/
│   ├── __init__.py
│   ├── database.py          # 数据库操作
│   └── exporter.py          # 数据导出
│
├── visualization/
│   ├── __init__.py
│   └── map_generator.py     # 地图生成
│
├── data/
│   ├── restaurants.db       # SQLite数据库
│   └── restaurants.csv      # 导出的CSV文件
│
├── output/
│   └── restaurant_map.html  # 生成的地图
│
├── logs/
│   └── scraper.log          # 日志文件
│
├── main.py                  # 主程序入口
├── requirements.txt         # 依赖清单
└── README.md               # 项目说明

获取高德API Key

  1. 访问 高德开放平台
  2. 注册并登录账号
  3. 进入"应用管理" → "我的应用" → "创建新应用"
  4. 添加Key(选择"Web服务"类型)
  5. 复制Key并保存到配置文件
python 复制代码
# config/settings.py
AMAP_API_KEY = 'your_api_key_here'  # 替换为你的Key
AMAP_BASE_URL = 'https://restapi.amap.com/v3/place/text'

# 爬取配置
REQUEST_DELAY = (2, 5)  # 请求延时范围(秒)
MAX_RETRIES = 3         # 最大重试次数
TIMEOUT = 10            # 请求超时(秒)

# 数据库配置
DB_PATH = 'data/restaurants.db'
CSV_PATH = 'data/restaurants.csv'

# 地图配置
MAP_OUTPUT = 'output/restaurant_map.html'
MAP_CENTER = [31.2304, 121.4737]  # 上海市中心坐标
MAP_ZOOM = 12

6️⃣ 核心实现:高德API调用模块

设计要点

高德POI搜索API的关键参数:

  • key: API密钥(必需)
  • keywords: 搜索关键词(如"火锅")
  • city: 城市名称或城市编码
  • types: POI类型编码(餐饮服务为050000)
  • page: 页码(从1开始)
  • offset: 每页返回数量(最大50)
  • extensions: 返回结果详细程度(base/all)

完整代码实现

python 复制代码
# scraper/amap_api.py
import requests
import time
import random
from typing import List, Dict, Optional
from config.settings import AMAP_API_KEY, AMAP_BASE_URL, REQUEST_DELAY, MAX_RETRIES, TIMEOUT

class AmapPOIFetcher:
    """高德地图POI数据获取器"""
    
    def __init__(self, api_key: str = AMAP_API_KEY):
        """
        初始化API调用器
        
        Args:
            api_key: 高德地图API密钥
        """
        self.api_key = api_key
        self.base_url = AMAP_BASE_URL
        self.session = requests.Session()
        
    def search_poi(
        self,
        keywords: str,
        city: str,
        poi_type: str = '050000',  # 050000=餐饮服务
        page: int = 1,
        offset: int = 50
    ) -> Optional[Dict]:
        """
        搜索POI数据(单页)
        
        Args:
            keywords: 搜索关键词,如"火锅"、"咖啡"
            city: 城市名称,如"上海"、"beijing"
            poi_type: POI类型编码,默认为餐饮服务
            page: 页码,从1开始
            offset: 每页数量,最大50
            
        Returns:
            API响应的JSON数据,失败返回None
            
        详细说明:
            - types参数说明:
              050000: 餐饮服务(大类)
              050100: 中餐厅
              050200: 外国餐厅
              050300: 快餐厅
              050500: 咖啡厅
              050600: 茶艺馆
              050700: 酒吧
            - 如果不指定types,会返回所有类型的POI
            - offset最大值为50,如果需要更多数据需要翻页
        """
        params = {
            'key': self.api_key,
            'keywords': keywords,
            'city': city,
            'types': poi_type,
            'page': page,
            'offset': offset,
            'extensions': 'all'  # 返回详细信息(包含评分)
        }
        
        retry_count = 0
        while retry_count < MAX_RETRIES:
            try:
                # 添加随机延时(避免频率过快)
                time.sleep(random.uniform(*REQUEST_DELAY))
                
                response = self.session.get(
                    self.base_url,
                    params=params,
                    timeout=TIMEOUT
                )
                
                # 检查HTTP状态码
                response.raise_for_status()
                
                # 解析JSON响应
                data = response.json()
                
                # 检查API返回状态
                # status=1表示成功,0表示失败
                if data.get('status') != '1':
                    print(f"❌ API错误: {data.get('info')}")
                    return None
                
                return data
                
            except requests.exceptions.Timeout:
                retry_count += 1
                print(f"⏱️ 请求超时,重试 {retry_count}/{MAX_RETRIES}")
                
            except requests.exceptions.HTTPError as e:
                print(f"❗ HTTP错误: {e.response.status_code}")
                return None
                
            except requests.exceptions.RequestException as e:
                retry_count += 1
                print(f"🔌 网络异常: {str(e)}, 重试 {retry_count}/{MAX_RETRIES}")
                
            except ValueError:  # JSON解析失败
                print(f"⚠️ 响应不是有效的JSON格式")
                return None
        
        print(f"❌ 达到最大重试次数,放弃请求")
        return None
    
    def search_all_pages(
        self,
        keywords: str,
        city: str,
        poi_type: str = '050000',
        max_results: int = 500
    ) -> List[Dict]:
        """
        搜索所有页面的POI数据(自动翻页)
        
        Args:
            keywords: 搜索关键词
            city: 城市名称
            poi_type: POI类型编码
            max_results: 最大返回结果数(高德API限制单次搜索最多1000条)
            
        Returns:
            POI数据列表
            
        实现逻辑:
            1. 先请求第1页,获取总数count
            2. 计算需要请求的总页数
            3. 循环请求每一页,合并结果
            4. 达到max_results或无更多数据时停止
        """
        all_pois = []
        page = 1
        offset = 50  # 每页50条(最大值)
        
        print(f"🔍 开始搜索: {city} - {keywords}")
        
        # 第一页请求(获取总数)
        first_page = self.search_poi(keywords, city, poi_type, page, offset)
        if not first_page:
            print(f"⚠️ 第一页请求失败,终止搜索")
            return []
        
        # 提取POI列表
        pois = first_page.get('pois', [])
        all_pois.extend(pois)
        
        # 获取总数
        total_count = int(first_page.get('count', 0))
        print(f"📊 共找到 {total_count} 条结果")
        
        # 计算总页数(向上取整)
        total_pages = (min(total_count, max_results) + offset - 1) // offset
        
        # 请求剩余页面
        for page in range(2, total_pages + 1):
            print(f"📄 正在获取第 {page}/{total_pages} 页...")
            
            page_data = self.search_poi(keywords, city, poi_type, page, offset)
            if not page_data:
                print(f"⚠️ 第{page}页请求失败,跳过")
                continue
            
            pois = page_data.get('pois', [])
            all_pois.extend(pois)
            
            # 达到最大结果数时停止
            if len(all_pois) >= max_results:
                all_pois = all_pois[:max_results]
                break
        
        print(f"✅ 搜索完成,共获取 {len(all_pois)} 条数据")
        return all_pois
    
    def parse_poi_data(self, poi: Dict) -> Dict:
        """
        解析单条POI数据,提取所需字段
        
        Args:
            poi: API返回的原始POI数据
            
        Returns:
            标准化后的POI字典
            
        字段说明:
            - id: POI唯一标识(高德内部ID)
            - name: 店中餐厅;火锅店")
            - address: 详细地址
            - location: 经纬度字符串(格式:"116.397128,39.916527")
            - longitude: 经度(浮点数)
            - latitude: 纬度(浮点数)
            - tel: 电话号码
            - biz_ext: 商业扩展信息(包含评分等)
        """
        # 提取基础信息
        poi_id = poi.get('id', '')
        name = poi.get('name', '')
        poi_type = poi.get('type', '')
        address = poi.get('address', '')
        
        # 解析经纬度
        location = poi.get('location', '0,0')
        try:
            lon, lat = location.split(',')
            longitude = float(lon)
            latitude = float(lat)
        except (ValueError, AttributeError):
            longitude, latitude = 0.0, 0.0
        
        # 提取联系方式
        tel = poi.get('tel', '')
        
        # 提取商业扩展信息(评分/人均消费等)
        biz_ext = poi.get('biz_ext', {})
        rating = biz_ext.get('rating', '0')  # 评分(字符串格式)
        cost = biz_ext.get('cost', '')       # 人均消费
        
        # 标准化数据
        return {
            'poi_id': poi_id,
            'name': name,
            'type': poi_type,
            'address': address,
            'longitude': longitude,
            'latitude': latitude,
            'tel': tel,
            'amap_rating': float(rating) if rating else 0.0,
            'avg_price': cost,
            'raw_data': poi  # 保留原始数据(用于调试)
        }


# 使用示例
if __name__ == '__main__':
    fetcher = AmapPOIFetcher()
    
    # 搜索上海的火锅店
    pois = fetcher.search_all_pages(
        keywords='火锅',
        city='上海',
        max_results=200
    )
    
    # 解析并打印前5条
    for poi in pois[:5]:
        parsed = fetcher.parse_poi_data(poi)
        print(f"店名: {parsed['name']}")
        print(f"地址: {parsed['address']}")
        print(f"评分: {parsed['amap_rating']}")
        print(f"坐标: ({parsed['longitude']}, {parsed['latitude']})")
        print('-' * 50)

代码详解

1. 为什么要用Session?
python 复制代码
self.session = requests.Session()
  • 连接复用: 同一个Session对象发起的多次请求会复用TCP连接,减少握手次数
  • Cookie管理: 自动维护Cookie(虽然这个API不需要,但养成好习惯)
  • 性能提升: 在循环请求多页数据时,速度提升明显
2. 重试机制的实现
python 复制代码
retry_count = 0
while retry_count < MAX_RETRIES:
    try:
        # 发起请求
        ...
    except requests.exceptions.Timeout:
        retry_count += 1  # 超时时重试

为什么要重试?

  • 网络波动导致的偶发性失败
  • 服务器短暂过载(返回5xx错误)
  • 避免因单次失败导致整个流程中断
3. 分页逻辑的处理
python 复制代码
# 计算总页数
total_pages = (min(total_count, max_results) + offset - 1) // offset

数学原理:

  • total_count = 237, offset = 50
  • 需要页数 = ⌈237 / 50⌉ = 5页
  • 公式 (237 + 50 - 1) // 50 = 286 // 50 = 5
4. 经纬度的解析
python 复制代码
location = "116.397128,39.916527"  # API返回格式
lon, lat = location.split(',')
longitude = float(lon)  # 116.397128
latitude = float(lat)   # 39.916527

坐标系说明:

  • 高德返回的是 GCJ-02坐标(火星坐标系)
  • 如果要在国外地图(Google/OpenStreetMap)使用,需要转换为WGS-84
  • 本项目使用Folium(基于Leaflet),支持GCJ-02

7️⃣ 核心实现:网页补充爬取模块

虽然高德API已经提供了基础信息,但大众点评的数据更丰富(详细评价、推荐菜品等)。我们可以通过店名+地址构造搜索URL,爬取补充信息。

代码实现

python 复制代码
# scraper/web_fetcher.py
import requests
import time
import random
from lxml import etree
from typing import Optional, Dict
from urllib.parse import quote
from config.settings import REQUEST_DELAY, TIMEOUT

class DianpingFetcher:
    """大众点评补充数据爬取器"""
    
    def __init__(self):
        self.session = requests.Session()
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'Accept': 'text/html,application/xhtml+xml',
            'Accept-Language': 'zh-CN,zh;q=0.9',
            'Referer': 'https://www.dianping.com'
        }
    
    def search_restaurant(self, name: str, city: str = '上海') -> Optional[str]:
        """
        搜索餐厅并获取详情页URL
        
        Args:
            name: 餐厅名称
            city: 城市名称
            
        Returns:
            详情页URL,未找到返回None
            
        实现思路:
            1. 构造搜索URL(如: /search/keyword/上海/0_火锅)
            2. 解析搜索结果页,找到第一个匹配的店铺链接
            3. 返回详情页URL
        """
        # URL编码(处理中文)
        encoded_name = quote(name)
        search_url = f'https://www.dianping.com/search/keyword/{city}/0_{encoded_name}'
        
        try:
            time.sleep(random.uniform(*REQUEST_DELAY))
            
            response = self.session.get(
                search_url,
                headers=self.headers,
                timeout=TIMEOUT
            )
            
            if response.status_code == 403:
                print(f"🚫 触发反爬(403): {name}")
                return None
            
            response.raise_for_status()
            
            # 解析搜索结果
            tree = etree.HTML(response.text)
            
            # 提取第一个店铺链接
            # XPath说明: 
            # //div[@id="shop-all-list"] 定位到结果列表容器
            # //a[contains(@class, "shopname")] 找到店名链接
            shop_links = tree.xpath('//div[@id="shop-all-list"]//a[contains(@class, "shopname")]/@href')
            
            if shop_links:
                # 拼接完整URL
                detail_url = 'https://www.dianping.com' + shop_links[0]
                return detail_url
            else:
                print(f"⚠️ 未找到搜索结果: {name}")
                return None
                
        except Exception as e:
            print(f"❗ 搜索失败: {name} | {str(e)}")
            return None
    
    def fetch_detail(self, url: str) -> Optional[Dict]:
        """
        抓取餐厅详情页数据
        
        Args:
            url: 详情页URL
            
        Returns:
            包含评分/评价/推荐菜等信息的字典
        """
        try:
            time.sleep(random.uniform(*REQUEST_DELAY))
            
            response = self.session.get(
                url,
                headers=self.headers,
                timeout=TIMEOUT
            )
            
            response.raise_for_status()
            tree = etree.HTML(response.text)
            
            # 提取评分
            # XPath解析: //span[@class="item"]/strong/text()
            # 可能返回: ['4', '.', '5'] 需要拼接
            rating_parts = tree.xpath('//div[@id="reviewScore"]//span[@class="item"]/strong/text()')
            dianping_rating = ''.join(rating_parts).strip() if rating_parts else '0'
            
            # 提取评价数量
            # 示例HTML: <a>(1234条评价)</a>
            review_count_text = tree.xpath('//span[@class="item"]/a/text()')
            review_count = 0
            if review_count_text:
                import re
                match = re.search(r'(\d+)', review_count_text[0])
                review_count = int(match.group(1)) if match else 0
            
            # 提取人均消费
            # 示例: <span class="item price">¥88/人</span>
            avg_price_text = tree.xpath('//div[@id="avgPriceTitle"]/text()')
            avg_price = avg_price_text[0].replace('¥', '').replace('/人', '').strip() if avg_price_text else '0'
            
            # 提取推荐菜品
            # 通常在 <div class="recommend-items"> 下
            dishes = tree.xpath('//div[@class="recommend-name"]/a/text()')
            recommend_dishes = ', '.join([d.strip() for d in dishes[:10]])  # 取前10个
            
            # 提取最新评价摘要
            reviews = tree.xpath('//div[@class="review-words"]//text()')
            recent_reviews = [r.strip() for r in reviews if r.strip()][:5]  # 前5条
            
            return {
                'dianping_rating': float(dianping_rating) if dianping_rating else 0.0,
                'review_count': review_count,
                'avg_price': avg_price,
                'recommend_dishes': recommend_dishes,
                'recent_reviews': '|||'.join(recent_reviews)
            }
            
        except Exception as e:
            print(f"❗ 详情页抓取失败: {url} | {str(e)}")
            return {
                'dianping_rating': 0.0,
                'review_count': 0,
                'avg_price': '0',
                'recommend_dishes': '',
                'recent_reviews': ''
            }


# 使用示例
if __name__ == '__main__':
    fetcher = DianpingFetcher()
    
    # 搜索并抓取详情
    detail_url = fetcher.search_restaurant('海底捞火锅', '上海')
    if detail_url:
        detail_info = fetcher.fetch_detail(detail_url)
        print(f"点评评分: {detail_info['dianping_rating']}")
        print(f"评价数: {detail_info['review_count']}")
        print(f"人均: ¥{detail_info['avg_price']}")
        print(f"推荐菜: {detail_info['recommend_dishes']}")

关键技术点解析

1. URL编码的处理
python 复制代码
from urllib.parse import quote
encoded_name = quote('火锅')  # 输出: %E7%81%AB%E9%94%85

为什么需要编码?

  • URL只能包含ASCII字符
  • 中文等非ASCII字符必须转换为 %XX 格式
  • quote() 函数自动处理空格、特殊符号
2. XPath选择器的容错
python 复制代码
# ❌ 错误写法(可能抛出IndexError)
rating = tree.xpath('//span[@class="rating"]/text()')[0]

# ✅ 正确写法(带默认值)
rating_list = tree.xpath('//span[@class="rating"]/text()')
rating = rating_list[0] if rating_list else '0'
3. 正则表达式提取数字
python 复制代码
import re
text = "(1234条评价)"
match = re.search(r'(\d+)', text)
number = int(match.group(1))  # 1234

解释:

  • \d+ 匹配连续的数字
  • group(1) 获取第一个捕获组的内容

8️⃣ 核心实现:地理工具模块

在处理POI数据时,经常需要进行坐标计算(如计算两点距离、判断是否在某区域内等)。

代码实现

python 复制代码
# scraper/geo_utils.py
import math
from typing import Tuple

class GeoUtils:
    """地理计算工具类"""
    
    # 地球平均半径(千米)
    EARTH_RADIUS = 6371.0
    
    @staticmethod
    def calculate_distance(
        point1: Tuple[float, float],
        point2: Tuple[float, float]
    ) -> float:
        """
        计算两点间的直线距离(Haversine公式)
        
        Args:
            point1: 第一个点的坐标 (经度, 纬度)
            point2: 第二个点的坐标 (经度, 纬度)
            
        Returns:
            距离(千米)
            
        公式说明:
            Haversine公式用于计算球面上两点间的最短距离
            a = sin²(Δlat/2) + cos(lat1) * cos(lat2) * sin²(Δlon/2)
            c = 2 * atan2(√a, √(1−a))
            d = R * c
        """
        lon1, lat1 = point1
        lon2, lat2 = point2
        
        # 转换为弧度
        lon1_rad = math.radians(lon1)
        lat1_rad = math.radians(lat1)
        lon2_rad = math.radians(lon2)
        lat2_rad = math.radians(lat2)
        
        # 计算差值
        dlon = lon2_rad - lon1_rad
        dlat = lat2_rad - lat1_rad
        
        # Haversine公式
        a = math.sin(dlat / 2)**2 + \
            math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
        c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
        
        distance = GeoUtils.EARTH_RADIUS * c
        return distance
    
    @staticmethod
    def is_within_bounds(
        point: Tuple[float, float],
        bounds: Dict[str, float]
    ) -> bool:
        """
        判断点是否在矩形边界内
        
        Args:
            point: 待判断的点 (经度, 纬度)
            bounds: 边界字典 {'min_lon': ..., 'max_lon': ..., 'min_lat': ..., 'max_lat': ...}
            
        Returns:
            True表示在边界内,False表示在边界外
            
        使用场景:
            过滤掉不在目标区域内的POI
            例如只要上海市中心的餐厅,排除郊区的
        """
        lon, lat = point
        return (bounds['min_lon'] <= lon <= bounds['max_lon'] and
                bounds['min_lat'] <= lat <= bounds['max_lat'])
    
    @staticmethod
    def get_center_point(points: List[Tuple[float, float]]) -> Tuple[float, float]:
        """
        计算多个点的中心点(质心)
        
        Args:
            points: 点的列表 [(lon1, lat1), (lon2, lat2), ...]
            
        Returns:
            中心点坐标 (经度, 纬度)
            
        用途:
            在地图上定位到所有餐厅的中心位置
        """
        if not points:
            return (0.0, 0.0)
        
        total_lon = sum(p[0] for p in points)
        total_lat = sum(p[1] for p in points)
        
        center_lon = total_lon / len(points)
        center_lat = total_lat / len(points)
        
        return (center_lon, center_lat)


# 使用示例
if __name__ == '__main__':
    utils = GeoUtils()
    
    # 测试距离计算
    # 上海人民广场: (121.475, 31.231)
    # 上海东方明珠: (121.506, 31.244)
    point1 = (121.475, 31.231)
    point2 = (121.506, 31.244)
    
    distance = utils.calculate_distance(point1, point2)
    print(f"人民广场到东方明珠的距离: {distance:.2f} 公里")
    # 输出: 约3.5公里
    
    # 测试边界判断
    shanghai_bounds = {
        'min_lon': 121.2, 'max_lon': 121.7,
        'min_lat': 31.0, 'max_lat': 31.5
    }
    
    print(f"是否在上海市区: {utils.is_within_bounds(point1, shanghai_bounds)}")
    # 输出: True

数学原理详解

Haversine公式推导
json 复制代码
给定两点A(lon1, lat1)和B(lon2, lat2)

1. 计算纬度差和经度差:
   Δlat = lat2 - lat1
   Δlon = lon2 - lon1

2. 计算参数a:
   a = sin²(Δlat/2) + cos(lat1) × cos(lat2) × sin²(Δlon/2)

3. 计算中心角c:
   c = 2 × arctan2(√a, √(1-a))

4. 计算距离:
   d = R × c  (R为地球半径6371km)

为什么用这个公式?

  • 地球是球体,两点间的最短距离是大圆弧
  • 直接用勾股定理计算平面距离会有较大误差
  • Haversine公式考虑了地球曲率,精度高

9️⃣ 数据存储与管理

数据库设计

python 复制代码
# storage/database.py
import sqlite3
from datetime import datetime
from typing import List, Dict, Optional
from config.settings import DB_PATH

class RestaurantDatabase:
    """餐饮POI数据库管理类"""
    
    def __init__(self, db_path: str = DB_PATH):
        self.db_path = db_path
        self._init_database()
    
    def _init_database(self):
        """初始化数据库表结构"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # 创建主表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS restaurants (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            poi_id TEXT UNIQUE NOT NULL,        -- 高德POI ID(唯一标识)
            name TEXT NOT NULL,                 -- 餐厅名称
            type TEXT,                          -- POI类型
            address TEXT,                       -- 详细地址
            longitude REAL NOT NULL,            -- 经度
            latitude REAL NOT NULL,             -- 纬度
            tel TEXT,                           -- 电话
            amap_rating REAL DEFAULT 0.0,       -- 高德评分
            dianping_rating REAL DEFAULT 0.0,   -- 点评评分
            review_count INTEGER DEFAULT 0,     -- 评价数量
            avg_price TEXT,                     -- 人均消费
            recommend_dishes TEXT,              -- 推荐菜品
            recent_reviews TEXT,                -- 最新评价摘要
            city TEXT,                          -- 所属城市
            district TEXT,                      -- 所属区域
            crawl_time TEXT NOT NULL,           -- 抓取时间
            update_time TEXT                    -- 更新时间
        )
        ''')
        
        # 创建索引(加速查询)
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_name ON restaurants(name)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_rating ON restaurants(amap_rating DESC)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_location ON restaurants(longitude, latitude)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_city ON restaurants(city)')
        
        # 创建全文搜索索引(可选,用于模糊搜索)
        try:
            cursor.execute('''
            CREATE VIRTUAL TABLE IF NOT EXISTS restaurants_fts 
            USING fts5(name, address, type, content=restaurants, content_rowid=id)
            ''')
        except sqlite3.OperationalError:
            pass  # FTS5不可用时跳过
        
        conn.commit()
        conn.close()
        
        print(f"✅ 数据库初始化完成: {self.db_path}")
    
    def insert_restaurant(self, data: Dict) -> bool:
        """
        插入单条餐厅数据
        
        Args:
            data: 餐厅信息字典
            
        Returns:
            成功返回True,失败返回False
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        try:
            current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            
            cursor.execute('''
            INSERT INTO restaurants (
                poi_id, name, type, address, longitude, latitude, tel,
                amap_rating, dianping_rating, review_count, avg_price,
                recommend_dishes, recent_reviews, city, district,
                crawl_time, update_time
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''', (
                data.get('poi_id'),
                data.get('name'),
                data.get('type'),
                data.get('address'),
                data.get('longitude'),
                data.get('latitude'),
                data.get('tel'),
                data.get('amap_rating', 0.0),
                data.get('dianping_rating', 0.0),
                data.get('review_count', 0),
                data.get('avg_price', '0'),
                data.get('recommend_dishes', ''),
                data.get('recent_reviews', ''),
                data.get('city', ''),
                data.get('district', ''),
                current_time,
                current_time
            ))
            
            conn.commit()
            return True
            
        except sqlite3.IntegrityError:
            # poi_id重复,尝试更新
            print(f"⏭️ 数据已存在,尝试更新: {data.get('name')}")
            return self.update_restaurant(data)
            
        except Exception as e:
            print(f"❌ 插入失败: {data.get('name')} | {str(e)}")
            return False
            
        finally:
            conn.close()
    
    def update_restaurant(self, data: Dict) -> bool:
        """更新已存在的餐厅数据"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        try:
            update_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            
            cursor.execute('''
            UPDATE restaurants SET
                name = ?, type = ?, address = ?, tel = ?,
                amap_rating = ?, dianping_rating = ?, review_count = ?,
                avg_price = ?, recommend_dishes = ?, recent_reviews = ?,
                update_time = ?
            WHERE poi_id = ?
            ''', (
                data.get('name'),
                data.get('type'),
                data.get('address'),
                data.get('tel'),
                data.get('amap_rating', 0.0),
                data.get('dianping_rating', 0.0),
                data.get('review_count', 0),
                data.get('avg_price', '0'),
                data.get('recommend_dishes', ''),
                data.get('recent_reviews', ''),
                update_time,
                data.get('poi_id')
            ))
            
            conn.commit()
            return True
            
        except Exception as e:
            print(f"❌ 更新失败: {str(e)}")
            return False
            
        finally:
            conn.close()
    
    def batch_insert(self, data_list: List[Dict]) -> int:
        """批量插入数据"""
        success_count = 0
        for data in data_list:
            if self.insert_restaurant(data):
                success_count += 1
        
        print(f"✅ 批量插入完成: 成功{success_count}/{len(data_list)}条")
        return success_count
    
    def query_by_rating(self, min_rating: float = 4.0, limit: int = 20) -> List[Dict]:
        """查询高分餐厅"""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row  # 返回字典格式
        cursor = conn.cursor()
        
        cursor.execute('''
        SELECT * FROM restaurants 
        WHERE amap_rating >= ? 
        ORDER BY amap_rating DESC, review_count DESC
        LIMIT ?
        ''', (min_rating, limit))
        
        results = [dict(row) for row in cursor.fetchall()]
        conn.close()
        
        return results

    def export_to_csv(self, output_path: str = 'data/restaurants.csv'):
        """导出为CSV文件"""
        import pandas as pd
        
        conn = sqlite3.connect(self.db_path)
        df = pd.read_sql_query('SELECT * FROM restaurants', conn)
        conn.close()
        
        df.to_csv(output_path, index=False, encoding='utf-8-sig')
        print(f"📊 已导出到 {output_path} ({len(df)} 条记录)")
        
        return output_path

数据清洗模块

python 复制代码
# storage/data_cleaner.py
import re
from typing import List, Dict

class DataCleaner:
    """数据清洗工具类"""
    
    @staticmethod
    def clean_phone_number(phone: str) -> str:
        """
        规范化电话号码
        
        处理场景:
            - 去除分机号: "021-12345678-8001" → "021-12345678"
            - 统一格式: "02112345678" → "021-12345678"
            - 去除空格和特殊字符: "021 1234 5678" → "021-12345678"
        """
        if not phone:
            return ''
        
        # 去除所有非数字和连字符
        phone = re.sub(r'[^\d-]', '', phone)
        
        # 去除分机号(通常在最后一个连字符后)
        if phone.count('-') > 1:
            parts = phone.split('-')
            phone = '-'.join(parts[:2])
        
        # 统一格式(区号-号码)
        if '-' not in phone and len(phone) >= 10:
            # 假设前3位或4位是区号
            if phone.startswith('0'):
                if len(phone) == 11:
                    phone = phone[:3] + '-' + phone[3:]
                elif len(phone) == 12:
                    phone = phone[:4] + '-' + phone[4:]
        
        return phone
    
    @staticmethod
    def clean_price(price_str: str) -> int:
        """
        提取价格数字
        
        处理场景:
            - "¥88/人" → 88
            - "人均88元" → 88
            - "88-120元" → 88(取下限)
        """
        if not price_str:
            return 0
        
        # 提取所有数字
        numbers = re.findall(r'\d+', str(price_str))
        if not numbers:
            return 0
        
        # 如果有多个数字(价格区间),取第一个
        return int(numbers[0])
    
    @staticmethod
    def deduplicate_by_location(
        restaurants: List[Dict],
        distance_threshold: float = 0.05  # 50米
    ) -> List[Dict]:
        """
        根据位置去重(距离过近的认为是同一家店)
        
        Args:
            restaurants: 餐厅列表
            distance_threshold: 距离阈值(千米)
            
        Returns:
            去重后的列表
            
        算法说明:
            1. 遍历每个餐厅
            2. 计算与已保留餐厅的距离
            3. 如果距离小于阈值且名称相似,认为重复
        """
        from scraper.geo_utils import GeoUtils
        from difflib import SequenceMatcher
        
        unique_restaurants = []
        
        for restaurant in restaurants:
            is_duplicate = False
            current_point = (restaurant['longitude'], restaurant['latitude'])
            current_name = restaurant['name']
            
            for existing in unique_restaurants:
                existing_point = (existing['longitude'], existing['latitude'])
                existing_name = existing['name']
                
                # 计算距离
                distance = GeoUtils.calculate_distance(current_point, existing_point)
                
                # 计算名称相似度
                similarity = SequenceMatcher(None, current_name, existing_name).ratio()
                
                # 距离近且名称相似,认为重复
                if distance < distance_threshold and similarity > 0.8:
                    is_duplicate = True
                    break
            
            if not is_duplicate:
                unique_restaurants.append(restaurant)
        
        removed_count = len(restaurants) - len(unique_restaurants)
        if removed_count > 0:
            print(f"🧹 去重完成: 移除 {removed_count} 条重复数据")
        
        return unique_restaurants
    
    @staticmethod
    def extract_district(address: str, city: str = '上海') -> str:
        """
        从地址中提取行政区
        
        Args:
            address: 完整地址
            city: 城市名称
            
        Returns:
            区名(如"浦东新区")
            
        实现逻辑:
            使用正则表达式匹配常见区名模式
        """
        # 上海的区名列表
        shanghai_districts = [
            '黄浦区', '徐汇区', '长宁区', '静安区', '普陀区',
            '虹口区', '杨浦区', '浦东新区', '闵行区', '宝山区',
            '嘉定区', '金山区', '松江区', '青浦区', '奉贤区', '崇明区'
        ]
        
        # 北京的区名列表
        beijing_districts = [
            '东城区', '西城区', '朝阳区', '丰台区', '石景山区',
            '海淀区', '门头沟区', '房山区', '通州区', '顺义区',
            '昌平区', '大兴区', '怀柔区', '平谷区', '密云区', '延庆区'
        ]
        
        # 根据城市选择区列表
        districts = shanghai_districts if city == '上海' else beijing_districts
        
        # 匹配区名
        for district in districts:
            if district in address:
                return district
        
        # 如果没匹配到,尝试正则提取
        match = re.search(r'(\w{2,4}区)', address)
        if match:
            return match.group(1)
        
        return '未知区域'


# 使用示例
if __name__ == '__main__':
    cleaner = DataCleaner()
    
    # 测试电话清洗
    print(cleaner.clean_phone_number('021-12345678-8001'))  # 021-12345678
    print(cleaner.clean_phone_number('021 1234 5678'))      # 021-12345678
    
    # 测试价格提取
    print(cleaner.clean_price('¥88/人'))      # 88
    print(cleaner.clean_price('人均88-120元')) # 88
    
    # 测试区域提取
    address = '上海市浦东新区陆家嘴环路1000号'
    print(cleaner.extract_district(address))  # 浦东新区

代码详解

1. 电话号码清洗的正则表达式
python 复制代码
phone = re.sub(r'[^\d-]', '', phone)

解释:

  • [^\d-] 表示"不是数字也不是连字符"的字符
  • re.sub(pattern, repl, string) 将匹配的字符替换为空字符串
  • 效果: "(021) 1234-5678""021-1234-5678"
2. 价格区间的处理
python 复制代码
numbers = re.findall(r'\d+', '88-120元')  # ['88', '120']
price = int(numbers[0])  # 取第一个: 88

为什么取第一个?

  • 通常价格区间的下限更有参考价值
  • 用户搜索"100元以内"时,用下限判断更准确
3. 位置去重的数学原理
python 复制代码
distance = GeoUtils.calculate_distance(point1, point2)
similarity = SequenceMatcher(None, name1, name2).ratio()

if distance < 0.05 and similarity > 0.8:  # 重复条件
    # 距离小于50米 且 名称相似度>80%

为什么需要双重判断?

  • 只判断距离:可能误删同一栋楼里的不同店
  • 只判断名称:可能误删连锁品牌的不同门店
  • 结合判断:同时满足"位置近+名称像"才认为重复

🔟 地图可视化实现

使用Folium生成交互式地图

python 复制代码
# visualization/map_generator.py
import folium
from folium.plugins import HeatMap, MarkerCluster
from typing import List, Dict, Tuple
from config.settings import MAP_OUTPUT, MAP_CENTER, MAP_ZOOM

class RestaurantMapGenerator:
    """餐厅地图生成器"""
    
    def __init__(self, center: List[float] = MAP_CENTER, zoom: int = MAP_ZOOM):
        """
        初始化地图
        
        Args:
            center: 地图中心坐标 [纬度, 经度]
            zoom: 缩放级别(1-18,数字越大越详细)
        """
        self.map = folium.Map(
            location=center,
            zoom_start=zoom,
            tiles='OpenStreetMap'  # 也可以用 'Stamen Terrain', 'CartoDB positron'
        )
    
    def add_markers(self, restaurants: List[Dict], cluster: bool = True):
        """
        添加餐厅标记点
        
        Args:
            restaurants: 餐厅数据列表
            cluster: 是否启用标记聚合(数据量大时推荐)
            
        标记说明:
            - 不同评分用不同颜色图标
            - 点击标记显示餐厅详细信息
        """
        if cluster:
            marker_cluster = MarkerCluster().add_to(self.map)
            target = marker_cluster
        else:
            target = self.map
        
        for restaurant in restaurants:
            # 跳过无效坐标
            if not restaurant.get('latitude') or not restaurant.get('longitude'):
                continue
            
            # 根据评分选择图标颜色
            rating = restaurant.get('amap_rating', 0)
            if rating >= 4.5:
                icon_color = 'red'      # 高分(红色)
            elif rating >= 4.0:
                icon_color = 'orange'   # 中高分(橙色)
            elif rating >= 3.5:
                icon_color = 'blue'     # 中等(蓝色)
            else:
                icon_color = 'gray'     # 低分(灰色)
            
            # 构造弹窗内容(HTML格式)
            popup_html = f"""
            <div style="width: 250px; font-family: Arial;">
                <h4 style="margin: 0; color: #333;">{restaurant.get('name', '未知')}</h4>
                <hr style="margin: 5px 0;">
                <p style="margin: 3px 0;">
                    <b>评分:</b> 
                    <span style="color: #ff6600; font-size: 16px;">
                        ★ {restaurant.get('amap_rating', 0):.1f}
                    </span>
                </p>
                <p style="margin: 3px 0;">
                    <b>人均:</b> ¥{restaurant.get('avg_price', '未知')}
                </p>
                <p style="margin: 3px 0;">
                    <b>地址:</b> {restaurant.get('address', '未知')}
                </p>
                <p style="margin: 3px 0;">
                    <b>电话:</b> {restaurant.get('tel', '未提供')}
                </p>
                <p style="margin: 3px 0;">
                    <b>推荐:</b> {restaurant.get('recommend_dishes', '暂无')[:50]}
                </p>
            </div>
            """
            
            # 添加标记
            folium.Marker(
                location=[restaurant['latitude'], restaurant['longitude']],
                popup=folium.Popup(popup_html, max_width=300),
                tooltip=restaurant.get('name'),  # 鼠标悬停显示店名
                icon=folium.Icon(color=icon_color, icon='cutlery', prefix='fa')
            ).add_to(target)
    
    def add_heatmap(self, restaurants: List[Dict], weight_by_rating: bool = True):
        """
        添加热力图层
        
        Args:
            restaurants: 餐厅数据列表
            weight_by_rating: 是否根据评分加权(高分餐厅权重大)
            
        用途:
            显示餐厅密集区域,帮助识别"美食街"
        """
        heat_data = []
        
        for restaurant in restaurants:
            lat = restaurant.get('latitude')
            lon = restaurant.get('longitude')
            
            if not lat or not lon:
                continue
            
            # 计算权重
            if weight_by_rating:
                rating = restaurant.get('amap_rating', 3.0)
                weight = max(rating, 1.0)  # 最低权重为1
            else:
                weight = 1.0
            
            heat_data.append([lat, lon, weight])
        
        # 添加热力图层
        HeatMap(
            heat_data,
            radius=15,           # 热力点半径
            blur=20,             # 模糊程度
            max_zoom=13,         # 最大缩放级别
            gradient={           # 自定义颜色梯度
                0.0: 'blue',
                0.5: 'lime',
                0.7: 'yellow',
                1.0: 'red'
            }
        ).add_to(self.map)
    
    def add_circle_area(
        self,
        center: Tuple[float, float],
        radius: float,
        label: str = '搜索范围'
    ):
        """
        添加圆形区域标记
        
        Args:
            center: 圆心坐标 (经度, 纬度)
            radius: 半径(米)
            label: 标签文字
            
        用途:
            标注搜索范围或商圈边界
        """
        folium.Circle(
            location=[center[1], center[0]],  # 注意:Folium用[纬度, 经度]
            radius=radius,
            color='blue',
            fill=True,
            fillColor='blue',
            fillOpacity=0.1,
            popup=label
        ).add_to(self.map)
    
    def add_top_restaurants_route(self, restaurants: List[Dict], top_n: int = 5):
        """
        连接评分最高的N家餐厅,生成美食路线
        
        Args:
            restaurants: 餐厅列表
            top_n: 取前N名
            
        用途:
            规划"一日吃遍高分餐厅"路线
        """
        # 按评分排序
        sorted_restaurants = sorted(
            restaurants,
            key=lambda x: x.get('amap_rating', 0),
            reverse=True
        )[:top_n]
        
        # 提取坐标点
        locations = [
            [r['latitude'], r['longitude']]
            for r in sorted_restaurants
            if r.get('latitude') and r.get('longitude')
        ]
        
        if len(locations) < 2:
            return
        
        # 添加路线
        folium.PolyLine(
            locations=locations,
            color='red',
            weight=3,
            opacity=0.7,
            popup=f'Top {top_n} 美食路线'
        ).add_to(self.map)
        
        # 在每个点添加编号标记
        for i, (restaurant, location) in enumerate(zip(sorted_restaurants, locations), 1):
            folium.Marker(
                location=location,
                icon=folium.DivIcon(html=f'''
                    <div style="
                        background-color: red;
                        color: white;
                        border-radius: 50%;
                        width: 30px;
                        height: 30px;
                        text-align: center;
                        line-height: 30px;
                        font-weight: bold;
                        font-size: 16px;
                    ">{i}</div>
                '''),
                popup=f"第{i}名: {restaurant.get('name')}"
            ).add_to(self.map)
    
    def save(self, output_path: str = MAP_OUTPUT):
        """保存地图为HTML文件"""
        self.map.save(output_path)
        print(f"🗺️ 地图已保存到: {output_path}")
        return output_path


# 使用示例
if __name__ == '__main__':
    # 模拟数据
    sample_data = [
        {
            'name': '海底捞火锅',
            'latitude': 31.2304,
            'longitude': 121.4737,
            'amap_rating': 4.6,
            'avg_price': '120',
            'address': '上海市黄浦区南京东路...',
            'tel': '021-12345678',
            'recommend_dishes': '毛肚, 鸭血, 虾滑'
        },
        # ... 更多数据
    ]
    
    # 生成地图
    map_gen = RestaurantMapGenerator(center=[31.2304, 121.4737])
    map_gen.add_markers(sample_data)
    map_gen.add_heatmap(sample_data)
    map_gen.save()

代码详解

1. Folium的坐标格式
python 复制代码
# ❌ 错误:经度在前
folium.Marker(location=[121.4737, 31.2304])

# ✅ 正确:纬度在前
folium.Marker(location=[31.2304, 121.4737])

注意事项:

  • Folium(基于Leaflet.js)使用 [纬度, 经度] 格式
  • 高德API返回的是 [经度, 纬度] 格式
  • 使用时需要注意顺序,否则地图会定位到错误位置
2. 标记聚合的必要性
python 复制代码
marker_cluster = MarkerCluster().add_to(self.map)

为什么要聚合?

  • 数据量大时(>100个点),所有标记都显示会很拥挤
  • 聚合后会自动合并为数字气泡(如"25")
  • 放大地图时自动展开,用户体验更好
3. HTML弹窗的自定义
python 复制代码
popup_html = f"""
<div style="width: 250px;">
    <h4>{restaurant['name']}</h4>
    <p>评分: ★ {restaurant['rating']:.1f}</p>
</div>
"""
folium.Popup(popup_html, max_width=300)

技巧:

  • 使用HTML+CSS自定义样式
  • max_width控制弹窗宽度
  • 可以嵌入图片、链接等元素

1️⃣1️⃣ 完整主程序实现

python 复制代码
# main.py
from scraper.amap_api import AmapPOIFetcher
from scraper.web_fetcher import DianpingFetcher
from scraper.geo_utils import GeoUtils
from storage.database import RestaurantDatabase
from storage.data_cleaner import DataCleaner
from visualization.map_generator import RestaurantMapGenerator
import time

def main():
    """主程序入口"""
    
    # ========== 配置参数 ==========
    CITY = '上海'
    KEYWORDS = '火锅'
    MAX_RESULTS = 200
    ENABLE_WEB_SCRAPING = True  # 是否启用网页补充爬取
    
    print("=" * 60)
    print(f"🚀 开始采集: {CITY} - {KEYWORDS}")
    print("=" * 60)
    
    # ========== 初始化模 {KEYWORDS}")
    print("=" * 60)
    
    # 块 ==========
    amap_fetcher = AmapPOIFetcher()
    dianping_fetcher = DianpingFetcher() if ENABLE_WEB_SCRAPING else None
    database = RestaurantDatabase()
    cleaner = DataCleaner()
    
    # ========== 步骤1: 通过高德API获取POI数据 ==========
    print("\n📡 步骤1: 调用高德API获取餐厅列表...")
    
    raw_pois = amap_fetcher.search_all_pages(
        keywords=KEYWORDS,
        city=CITY,
        max_results=MAX_RESULTS
    )
    
    if not raw_pois:
        print("❌ 未获取到任何数据,程序退出")
        return
    
    # 解析POI数据
    restaurants = []
    for poi in raw_pois:
        parsed = amap_fetcher.parse_poi_data(poi)
        
        # 数据清洗
        parsed['tel'] = cleaner.clean_phone_number(parsed.get('tel', ''))
        parsed['city'] = CITY
        parsed['district'] = cleaner.extract_district(parsed.get('address', ''), CITY)
        
        restaurants.append(parsed)
    
    print(f"✅ API数据获取完成,共 {len(restaurants)} 条")
    
    # ========== 步骤2: 去重处理 ==========
    print("\n🧹 步骤2: 数据去重...")
    
    restaurants = cleaner.deduplicate_by_location(restaurants)
    print(f"✅ 去重后剩余 {len(restaurants)} 条")
    
    # ========== 步骤3: 补充爬取点评网数据 ==========
    if ENABLE_WEB_SCRAPING and dianping_fetcher:
        print("\n🌐 步骤3: 补充大众点评数据...")
        print("⚠️ 此步骤较慢,请耐心等待...")
        
        for i, restaurant in enumerate(restaurants, 1):
            print(f"  [{i}/{len(restaurants)}] {restaurant['name']} ...", end=' ')
            
            # 搜索详情页
            detail_url = dianping_fetcher.search_restaurant(
                name=restaurant['name'],
                city=CITY
            )
            
            if detail_url:
                # 抓取详情
                detail_info = dianping_fetcher.fetch_detail(detail_url)
                
                # 合并数据
                restaurant.update(detail_info)
                
                # 清洗价格
                restaurant['avg_price'] = str(cleaner.clean_price(
                    restaurant.get('avg_price', '0')
                ))
                
                print(f"✅ (点评评分: {detail_info.get('dianping_rating', 0)})")
            else:
                print("⏭️ 未找到")
            
            # 控制频率(每20条休息一下)
            if i % 20 == 0:
                print("  ⏸️ 休息10秒,避免触发反爬...")
                time.sleep(10)
    else:
        print("\n⏭️ 步骤3: 跳过网页爬取")
    
    # ========== 步骤4: 存储到数据库 ==========
    print("\n💾 步骤4: 存储到数据库...")
    
    database.batch_insert(restaurants)
    
    # ========== 步骤5: 导出CSV ==========
    print("\n📊 步骤5: 导出数据...")
    
    csv_path = database.export_to_csv()
    
    # ========== 步骤6: 生成地图 ==========
    print("\n🗺️ 步骤6: 生成交互式地图...")
    
    # 计算中心点
    center_lon, center_lat = GeoUtils.get_center_point([
        (r['longitude'], r['latitude'])
        for r in restaurants
        if r.get('longitude') and r.get('latitude')
    ])
    
    # 初始化地图
    map_gen = RestaurantMapGenerator(center=[center_lat, center_lon])
    
    # 添加标记点(启用聚合)
    map_gen.add_markers(restaurants, cluster=True)
    
    # 添加热力图
    map_gen.add_heatmap(restaurants, weight_by_rating=True)
    
    # 添加Top5路线
    map_gen.add_top_restaurants_route(restaurants, top_n=5)
    
    # 保存地图
    map_path = map_gen.save()
    
    # ========== 完成总结 ==========
    print("\n" + "=" * 60)
    print("🎉 采集完成!数据统计:")
    print("=" * 60)
    print(f"📍 采集城市: {CITY}")
    print(f"🔍 搜索关键词: {KEYWORDS}")
    print(f"📊 总数据量: {len(restaurants)} 条")
    print(f"⭐ 平均评分: {sum(r.get('amap_rating', 0) for r in restaurants) / len(restaurants):.2f}")
    print(f"💰 平均价格: ¥{sum(cleaner.clean_price(r.get('avg_price', 0)) for r in restaurants) / len(restaurants):.0f}")
    print(f"\n📂 输出文件:")
    print(f"  - 数据库: {database.db_path}")
    print(f"  - CSV文件: {csv_path}")
    print(f"  - 地图文件: {map_path}")
    print("=" * 60)
    
    # ========== 数据分析示例 ==========
    print("\n📈 快速数据分析:")
    
    # 评分分布
    high_rated = [r for r in restaurants if r.get('amap_rating', 0) >= 4.5]
    mid_rated = [r for r in restaurants if 4.0 <= r.get('amap_rating', 0) < 4.5]
    low_rated = [r for r in restaurants if r.get('amap_rating', 0) < 4.0]
    
    print(f"  高分(≥4.5): {len(high_rated)} 家 ({len(high_rated)/len(restaurants)*100:.1f}%)")
    print(f"  中等(4.0-4.5): {len(mid_rated)} 家 ({len(mid_rated)/len(restaurants)*100:.1f}%)")
    print(f"  低分(<4.0): {len(low_rated)} 家 ({len(low_rated)/len(restaurants)*100:.1f}%)")
    
    # Top5餐厅
    print(f"\n🏆 评分Top5:")
    top5 = sorted(restaurants, key=lambda x: x.get('amap_rating', 0), reverse=True)[:5]
    for i, r in enumerate(top5, 1):
        print(f"  {i}. {r['name']} - ⭐{r.get('amap_rating', 0):.1f} - {r.get('address', '')[:30]}")


if __name__ == '__main__':
    main()

程序运行示例

bash 复制代码
$ python main.py

============================================================
🚀 开始采集: 上海 - 火锅
============================================================

📡 步骤1: 调用高德API获取餐厅列表...
🔍 开始搜索: 上海 - 火锅
📊 共找到 487 条结果
📄 正在获取第 2/4 页...
📄 正在获取第 3/4 页...
📄 正在获取第 4/4 页...
✅ 搜索完成,共获取 200 条数据
✅ API数据获取完成,共 200 条

🧹 步骤2: 数据去重...
🧹 去重完成: 移除 12 条重复数据
✅ 去重后剩余 188 条

🌐 步骤3: 补充大众点评数据...
⚠️ 此步骤较慢,请耐心等待...
  [1/188] 海底捞火锅(人民广场店) ... ✅ (点评评分: 4.5)
  [2/188] 小龙坎火锅(南京西路店) ... ✅ (点评评分: 4.3)
  ...
  [20/188] ... ⏸️ 休息10秒,避免触发反爬...
  ...
  [188/188] 完成

💾 步骤4: 存储到数据库...
✅ 批量插入完成: 成功188/188条

📊 步骤5: 导出数据...
📊 已导出到 data/restaurants.csv (188 条记录)

🗺️ 步骤6: 生成交互式地图...
🗺️ 地图已保存到: output/restaurant_map.html

============================================================
🎉 采集完成!数据统计:
============================================================
📍 采集城市: 上海
🔍 搜索关键词: 火锅
📊 总数据量: 188 条
⭐ 平均评分: 4.32
💰 平均价格: ¥95

📂 输出文件:
  - 数据库: data/restaurants.db
  - CSV文件: data/restaurants.csv
  - 地图文件: output/restaurant_map.html
============================================================

📈 快速数据分析:
  高分(≥4.5): 67 家 (35.6%)
  中等(4.0-4.5): 98 家 (52.1%)
  低分(<4.0): 23 家 (12.2%)

🏆 评分Top5:
  1. 海底捞火锅(人民广场店) - ⭐4.8 - 上海市黄浦区南京东路...
  2. 小龙坎火锅(南京西路店) - ⭐4.7 - 上海市静安区南京西路...
  3. 蜀大侠火锅(淮海路店) - ⭐4.7 - 上海市黄浦区淮海中路...
  4. 大龙燚火锅(徐家汇店) - ⭐4.6 - 上海市徐汇区肇嘉浜路...
  5. 珮姐老火锅(打浦桥店) - ⭐4.6 - 上海市黄浦区打浦路...

1️⃣2️⃣ 运行方式与结果展示

环境安装

bash 复制代码
# 1. 克隆项目(或下载)
git clone https://github.com/yourusername/restaurant-poi-scraper.git
cd restaurant-poi-scraper

# 2. 安装依赖
pip install -r requirements.txt

# 3. 配置API Key
# 编辑 config/settings.py,填入你的高德API Key
nano config/settings.py

基础运行

bash 复制代码
# 直接运行(使用默认配置)
python main.py

高级用法

python 复制代码
# 自定义城市和关键词
python -c "
from main import main
import config.settings as settings

settings.CITY = '北京'
settings.KEYWORDS = '咖啡'
settings.MAX_RESULTS = 100

main()
"

输出文件说明

1. CSV文件 (data/restaurants.csv)
列名 示例值 说明
poi_id B001D0TG7K 高德POI唯一ID
name 海底捞火锅 餐厅名称
type 中餐厅;火锅店 POI类型
address 上海市黄浦区南京东路... 详细地址
longitude 121.4850 经度
latitude 31.2389 纬度
tel 021-12345678 电话
amap_rating 4.6 高德评分
dianping_rating 4.5 点评评分
review_count 1234 评价数
avg_price 120 人均消费
recommend_dishes 毛肚,鸭血,虾滑 推荐菜品
recent_reviews 很好吃
city 上海 城市
district 黄浦区 行政区
2. 交互式地图 (output/restaurant_map.html)

功能特性:

  • ✅ 点击标记查看餐厅详情
  • ✅ 热力图显示餐厅密集区域
  • ✅ 不同颜色标记不同评分等级
  • ✅ Top5美食路线规划
  • ✅ 支持缩放和平移

使用方法:

bash 复制代码
# 双击打开HTML文件,或
open output/restaurant_map.html  # macOS
start output/restaurant_map.html # Windows
xdg-open output/restaurant_map.html # Linux
3. 数据库查询示例
python 复制代码
import sqlite3

conn = sqlite3.connect('data/restaurants.db')
cursor = conn.cursor()

# 查询浦东新区的高分餐厅
cursor.execute('''
SELECT name, amap_rating, avg_price, address
FROM restaurants
WHERE district = '浦东新区' AND amap_rating >= 4.5
ORDER BY amap_rating DESC
LIMIT 10
''')

for row in cursor.fetchall():
    print(f"{row[0]} - ⭐{row[1]} - ¥{row[2]} - {row[3]}")

conn.close()

1️⃣3️⃣ 常见问题与排错

Q1: API返回 "INVALID_USER_KEY"

原因:API Key配置错误或未激活

解决方案:

python 复制代码
# 检查config/settings.py中的Key是否正确
AMAP_API_KEY = 'your_actual_key_here'

# 确认Key的服务类型
# 高德控制台 → 应用管理 → 查看Key详情
# 确保"服务平台"选择了"Web服务"

Q2: API返回数据为空(count=0)

原因:

  • 关键词拼写错误
  • 城市名称不规范
  • 该城市确实没有相关POI

排查步骤:

python 复制代码
# 1. 测试基础搜索
pois = fetcher.search_poi('餐饮', '上海', pageget('count'))  # 应该返回一个大数字

# 2. 确认城市编码
# 可以用城市编码替代城市名
# 上海:310000, 北京:110000
pois = fetcher.search_poi('火锅', '310000', page=1)

# 3. 检查types参数
# 不指定types,返回所有类型POI
pois = fetcher.search_poi('火锅', '上海', poi_type='', page=1)

Q3: 网页爬取返回403或空页面

原因:触发反爬机制

解决方案:

python 复制代码
# 方案1:增加延时
REQUEST_DELAY = (5, 10)  # 改为5-10秒

# 方案2:更换User-Agent
headers['User-Agent'] = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)'

# 方案3:使用代理IP(需购买)
proxies = {
    'http': 'http://proxy-ip:port',
    'https': 'https://proxy-ip:port'
}
response = session.get(url, proxies=proxies)

# 方案4:降低并发,分批次抓取
# 每抓取50条就休息1分钟
if i % 50 == 0:
    time.sleep(60)

Q4: XPath解析不到数据

原因:

  • 网页结构变化了
  • XPath选择器写错了
  • 页面是动态加载的

调试方法:

python 复制代码
# 1. 打印HTML源码
print(response.text[:1000])

# 2. 用浏览器复制XPath对比
# Chrome: 右键元素 → 检查 → 右键HTML → Copy → Copy XPath

# 3. 使用更宽松的选择器
# ❌ 太严格: //div[@class="name"]
# ✅ 更灵活: //div[contains(@class, "name")]

# 4. 检查是否是动态加载
# 按F12 → Network → XHR,看是否有JSON接口

Q5: 坐标在地图上显示错误

原因:经纬度顺序颠倒

正确写法:

python 复制代码
# 高德API返回: "116.397128,39.916527" (经度,纬度)
lon, lat = location.split(',')

# Folium使用: [纬度, 经度]
folium.Marker(location=[lat, lon])  # ✅ 正确
folium.Marker(location=[lon, lat])  # ❌ 错误

Q6: 内存占用过高

原因:一次性加载太多数据到内存

优化方案:

python 复制代码
# 1. 分批处理
def batch_process(items, batch_size=50):
    for i in range(0, len(items), batch_size):
        batch = items[i:i + batch_size]
        process_batch(batch)
        # 每批处理后立即存储,释放内存
        del batch

# 2. 使用生成器
def poi_generator(keywords, city):
    for page in range(1, 20):
        pois = fetcher.search_poi(keywords, city, page=page)
        for poi in pois.get('pois', []):
            yield poi

# 逐条处理,不占用大内存
for poi in poi_generator('火锅', '上海'):
    process_and_save(poi)

1️⃣4️⃣ 进阶优化

1. 并发加速(异步版本)

python 复制代码
import asyncio
import aiohttp
from typing import List

class AsyncAmapFetcher:
    """异步版高德API调用器"""
    
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.base_url = 'https://restapi.amap.com/v3/place/text'
    
    async def fetch_page(
        self,
        session: aiohttp.ClientSession,
        page: int,
        keywords: str,
        city: str
    ) -> dict:
        """异步获取单页数据"""
        params = {
            'key': self.api_key,
            'keywords': keywords,
            'city': city,
            'page': page,
            'offset': 50
        }
        
        async with session.get(self.base_url, params=params) as response:
            return await response.json()
    
    async def fetch_all_async(
        self,
        keywords: str,
        city: str,
        max_pages: int = 10
    ) -> List[dict]:
        """并发获取多页数据"""
        async with aiohttp.ClientSession() as session:
            tasks = [
                self.fetch_page(session, page, keywords, city)
                for page in range(1, max_pages + 1)
            ]
            
            # 并发执行所有请求
            results = await asyncio.gather(*tasks)
            
            # 合并所有POI
            all_pois = []
            for result in results:
                pois = result.get('pois', [])
                all_pois.extend(pois)
            
            return all_pois


# 使用示例
async def main_async():
    fetcher = AsyncAmapFetcher(AMAP_API_KEY)
    pois = await fetcher.fetch_all_async('火锅', '上海', max_pages=4)
    print(f"异步获取完成: {len(pois)} 条数据")

# 运行
asyncio.run(main_async())

性能对比:

  • 同步版:4页数据 ≈ 8-12秒 (每页2-3秒)
  • 异步版:4页数据 ≈ 2-3秒 (并发请求)
  • 提速3-4倍

2. 断点续跑(支持中断恢复)

python 复制代码
import json
import os

class CheckpointManager:
    """断点管理器"""
    
    def __init__(self, checkpoint_file: str = 'data/checkpoint.json'):
        self.checkpoint_file = checkpoint_file
        self.checkpoint = self.load()
    
    def load(self) -> dict:
        """加载断点信息"""
        if os.path.exists(self.checkpoint_file):
            with open(self.checkpoint_file, 'r') as f:
                return json.load(f)
        return {
            'processed_ids': [],
            'current_page': 1,
            'total_processed': 0
        }
    
    def save(self):
        """保存断点信息"""
        with open(self.checkpoint_file, 'w') as f:
            json.dump(self.checkpoint, f, indent=2)
    
    def is_processed(self, poi_id: str) -> bool:
        """检查是否已处理"""
        return poi_id in self.checkpoint['processed_ids']
    
    def mark_processed(self, poi_id: str):
        """标记为已处理"""
        self.checkpoint['processed_ids'].append(poi_id)
        self.checkpoint['total_processed'] += 1
        
        # 每处理10条保存一次
        if self.checkpoint['total_processed'] % 10 == 0:
            self.save()


# 在主程序中使用
def main_with_checkpoint():
    checkpoint = CheckpointManager()
    
    # 从断点页开始
    start_page = checkpoint.checkpoint['current_page']
    
    for page in range(start_page, max_pages + 1):
        pois = fetch_page(page)
        
        for poi in pois:
            poi_id = poi['id']
            
            # 跳过已处理的
            if checkpoint.is_processed(poi_id):
                continue
            
            # 处理数据
            process_and_save(poi)
            
            # 标记完成
            checkpoint.mark_processed(poi_id)
        
        # 更新当前页
        checkpoint.checkpoint['current_page'] = page + 1
        checkpoint.save()
    
    print(f"✅ 全部完成,共处理 {checkpoint.checkpoint['total_processed']} 条")

3. 日志与监控

python 复制代码
import logging
from logging.handlers import RotatingFileHandler

def setup_logger():
    """配置日志系统"""
    logger = logging.getLogger('restaurant_scraper')
    logger.setLevel(logging.INFO)
    
    # 文件处理器(自动轮转,每个文件最大10MB)
    file_handler = RotatingFileHandler(
        'logs/scraper.log',
        maxBytes=10*1024*1024,  # 10MB
        backupCount=5  # 保留5个备份
    )
    file_handler.setLevel(logging.INFO)
    
    # 控制台处理器
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)
    
    # 格式化
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )
    file_handler.setFormatter(formatter)
    console_handler.setFormatter(formatter)
    
    logger.addHandler(file_handler)
    logger.addHandler(console_handler)
    
    return logger


# 使用示例
logger = setup_logger()

logger.info("开始采集数据")
logger.warning("触发反爬,延长延时")
logger.error("网络异常", exc_info=True)  # 记录完整堆栈

# 统计成功率
success_count = 150
total_count = 200
success_rate = success_count / total_count * 100
logger.info(f"成功率: {success_rate:.2f}% ({success_count}/{total_count})")

4. 定时任务(每日自动更新)

python 复制代码
from apscheduler.schedulers.blocking import BlockingScheduler
from datetime import datetime

def daily_update_task():
    """每日更新任务"""
    print(f"\n{'='*60}")
    print(f"开始执行定时任务: {datetime.now()}")
    print(f"{'='*60}\n")
    
    # 执行主程序
    main()
    
    print(f"\n{'='*60}")
    print(f"定时任务完成: {datetime.now()}")
    print(f"{'='*60}\n")


if __name__ == '__main__':
    scheduler = BlockingScheduler()
    
    # 每天凌晨2点执行
    scheduler.add_job(
        daily_update_task,
        'cron',
        hour=2,
        minute=0
    )
    
    print("📅 定时任务已启动")
    print("⏰ 执行时间: 每天 02:00")
    print("按 Ctrl+C 停止")
    
    try:
        scheduler.start()
    except (KeyboardInterrupt, SystemExit):
        print("\n定时任务已停止")

1️⃣5️⃣ 数据分析与应用场景

1. 评分与价格关系分析

python 复制代码
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# 读取数据
df = pd.read_csv('data/restaurants.csv')

# 清洗价格数据
df['avg_price_num'] = df['avg_price'].apply(lambda x: int(x) if str(x).isdigit() else 0)
df = df[df['avg_price_num'] > 0]

# 绘制散点图
plt.figure(figsize=(10, 6))
plt.scatter(df['avg_price_num'], df['amap_rating'], alpha=0.5)
plt.xlabel('人均消费(元)')
plt.ylabel('评分')
plt.title('餐厅评分与价格关系')
plt.grid(True, alpha=0.3)
plt.savefig('output/price_rating_analysis.png', dpi=300)
plt.show()

# 计算相关系数
correlation = df['avg_price_num'].corr(df['amap_rating'])
print(f"价格与评分的相关系数: {correlation:.3f}")

2. 区域餐饮饱和度分析

python 复制代码
# 统计各区餐厅数量
district_counts = df['district'].value_counts()

# 绘制柱状图
plt.figure(figsize=(12, 6))
district_counts.plot(kind='bar')
plt.title('各区域餐厅数量分布')
plt.xlabel('区域')
plt.ylabel('餐厅数量')
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig('output/district_distribution.png', dpi=300)
plt.show()

# 找出美食荒漠
print("\n餐厅数量排名:")
for i, (district, count) in enumerate(district_counts.items(), 1):
    print(f"{i}. {district}: {count}家")

3. 推荐菜品词云分析

python 复制代码
from wordcloud import WordCloud

# 合并所有推荐菜品
all_dishes = ' '.join(df['recommend_dishes'].dropna())

# 生成词云
wordcloud = WordCloud(
    width=800,
    height=400,
    background_color='white',
    font_path='/System/Library/Fonts/STHeiti Medium.ttc'  # Mac中文字体
).generate(all_dishes)

# 显示
plt.figure(figsize=(12, 6))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis('off')
plt.title('热门推荐菜品词云')
plt.savefig('output/dishes_wordcloud.png', dpi=300)
plt.show()

1️⃣6️⃣ 总结与延伸阅读

我们完成了什么?

从零构建了一个生产级餐饮POI数据采集系统,核心能力包括:

数据获取层:

  • 高德地图API的专业调用(分页/重试/异常处理)
  • 网页爬取的反反爬策略(延时/UA/频控)
  • 坐标系统的正确使用(GCJ-02)

数据处理层:

  • 多源数据融合(API + 网页)
  • 智能去重算法(位置+名称双重匹配)
  • 字段清洗与标准化(电话/价格/地址)

数据存储层:

  • SQLite数据库设计(索引优化/唯一约束)
  • 数据导出(CSV/JSON)
  • 断点续跑机制

可视化层:

  • Folium交互式地图(标记/热力图/路线)
  • 数据分析图表(评分分布/价格关系)
  • 词云分析(推荐菜品)

实际应用场景

1. 商业选址分析
python 复制代码
# 分析目标区域的竞争环境
target_district = '浦东新区'
competitors = df[df['district'] == target_district]

avg_rating = competitors['amap_rating'].mean()
avg_price = competitors['avg_price_num'].mean()

print(f"{target_district}火锅市场:")
print(f"- 竞争对手数量: {len(competitors)}家")
print(f"- 平均评分: {avg_rating:.2f}")
print(f"- 平均价格: ¥{avg_price:.0f}")
print(f"- 建议定价: ¥{avg_price * 0.9:.0f} - ¥{avg_price * 1.1:.0f}")
2. 个性化推荐系统
python 复制代码
def recommend_nearby(user_location, radius=2.0, min_rating=4.0):
    """推荐附近的高分餐厅"""
    results = []
    
    for _, restaurant in df.iterrows():
        poi_location = (restaurant['longitude'], restaurant['latitude'])
        distance = GeoUtils.calculate_distance(user_location, poi_location)
        
        if distance <= radius and restaurant['amap_rating'] >= min_rating:
            results.append({
                'name': restaurant['name'],
                'rating': restaurant['amap_rating'],
                'distance': distance,
                'price': restaurant['avg_price']
            })
    
    # 按距离排序
    results.sort(key=lambda x: x['distance'])
    return results[:10]

# 使用
user_pos = (121.4737, 31.2304)  # 人民广场
recommendations = recommend_nearby(user_pos)

for i, r in enumerate(recommendations, 1):
    print(f"{i}. {r['name']} - ⭐{r['rating']} - {r['distance']:.1f}km - ¥{r['price']}")
3. 价格监控预警
python 复制代码
def price_monitor():
    """监控价格变化,发现异常波动"""
    # 读取历史数据
    history = pd.read_csv('data/price_history.csv')
    current = pd.read_csv('data/restaurants.csv')
    
    for _, restaurant in current.iterrows():
        poi_id = restaurant['poi_id']
        current_price = restaurant['avg_price_num']
        
        # 查找历史价格
        hist_prices = history[history['poi_id'] == poi_id]['avg_price']
        
        if len(hist_prices) > 0:
            avg_price = hist_prices.mean()
            
            # 价格上涨超过20%
            if current_price > avg_price * 1.2:
                print(f"⚠️ 涨价预警: {restaurant['name']}")
                print(f"   历史均价: ¥{avg_price:.0f}")
                print(f"   当前价格: ¥{current_price}")
                print(f"   涨幅: {(current_price/avg_price-1)*100:.1f}%\n")

技术栈对比与选型

需求场景 推荐方案 理由
小规模采集(<1000条) requests + lxml 轻量级,易调试
中等规模(1000-10000) Scrapy框架 自动去重,断点续爬
大规模(>10000) Scrapy-Redis分布式 多机协同,速度快
动态页面 Playwright/Selenium 支持JS渲染
API调用 aiohttp异步 并发高效
数据存储(临时) CSV/JSON 简单直接
数据存储(长期) MySQL/PostgreSQL 支持复杂查询
地图可视化 Folium 交互式,易用
数据分析 pandas + matplotlib 生态丰富

下一步可以做什么?

1. 升级到Scrapy框架
bash 复制代码
pip install scrapy

# 创建项目
scrapy startproject restaurant_spider
cd restaurant_spider

# 创建爬虫
scrapy genspider amap_spider amap.com

Scrapy优势:

  • 自动处理请求队道(Pipeline)处理数据
  • 中间件(Middleware)扩展功能
2. 接入机器学习模型
python 复制代码
from sklearn.ensemble import RandomForestRegressor

# 训练评分预测模型
X = df[['avg_price_num', 'review_count', 'district_code']]
y = df['amap_rating']

model = RandomForestRegressor()
model.fit(X, y)

# 预测新店评分
new_restaurant = [[88, 100, 5]]  # 人均88,100条评价,浦东新区
predicted_rating = model.predict(new_restaurant)
print(f"预测评分: {predicted_rating[0]:.2f}")
3. 搭建Web服务
python 复制代码
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route('/api/search')
def search_restaurants():
    """搜索餐厅API"""
    keyword = request.args.get('keyword')
    city海')
    
    # 从数据库查询
    results = database.query_by_keyword(keyword, city)
    
    return jsonify({
        'status': 'success',
        'count': len(results),
        'data': results
    })

@app.route('/api/map')
def generate_map():
    """生成地图API"""
    restaurants = database.get_all()
    map_path = map_generator.generate(restaurants)
    return jsonify({'map_url': map_path})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

推荐学习资源

📚 书籍:

  • 《Python网络爬虫权威指南》(Ryan Mitchell)
  • 《精通Scrapy网络爬虫》(刘硕)
  • 《利用Python进行数据分析》(Wes McKinney)

🔗 在线文档:

🎥 视频教程:

  • B站"数据分析那些事"频道
  • Coursera"Python数据科学"课程
  • YouTube"Corey Schafer"Python系列

⚖️ 法律合规:

  • 《网络安全法》相关条款
  • 《个人信息保护法》
  • 各平台的robots.txt协议

最后的话

数据采集不仅是技术活,更是细心活。从API调用到网页解析,从数据清洗到可视化,每一步都需要考虑健壮性、合规性和可维护性

核心原则:

  1. 合规第一:遵守法律法规,尊重robots.txt
  2. 质量优先:数据准确性比数量更重要
  3. 用户友好:清晰的日志,友好的错误提示
  4. 持续优化:根据实际情况调整策略

希望这篇教程能帮你建立完整的POI数据采集思维框架,在实际项目中游刃有余!🚀

记住:

技术是手段,数据是资源,价值是目的。

有任何问题,欢迎交流讨论!💬✨

附录:完整代码仓库结构

json 复制代码
restaurant-poi-scraper/
├── README.md                    # 项目说明
├── requirements.txt             # 依赖清单
├── main.py                      # 主程序入口
│
├── config/
│   ├── __init__.py
│   └── settings.py              # 配置文件
│
├── scraper/
│   ├── __init__.py
│   ├── amap_api.py              # 高德API模块(300行)
│   ├── web_fetcher.py           # 网页爬取模块(200行)
│   ├── parser.py                # 数据解析模块(150行)
│   └── geo_utils.py             # 地理工具模块(100行)
│
├── storage/
│   ├── __init__.py
│   ├── database.py              # 数据库操作(250行)
│   ├── data_cleaner.py          # 数据清洗(180行)
│   └── exporter.py              # 数据导出(80行)
│
├── visualization/
│   ├── __init__.py
│   └── map_generator.py         # 地图生成(220行)
│
├── data/
│   ├── restaurants.db           # SQLite数据库
│   ├── restaurants.csv          # CSV导出
│   └── checkpoint.json          # 断点记录
│
├── output/
│   ├── restaurant_map.html      # 交互式地图
│   └── analysis_charts/         # 分析图表
│
├── logs/
│   └── scraper.log              # 运行日志
│
└── tests/
    ├── test_api.py              # API测试
    ├── test_parser.py           # 解析测试
    └── test_geo.py              # 地理计算测试

🌟 文末

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

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

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

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

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

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

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

评论区留言告诉我你的需求,我会优先安排更新 ✅


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

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

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


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!

相关推荐
70asunflower2 小时前
Python网络内容下载框架教程
开发语言·网络·python
datascome2 小时前
文章自动采集发布Zblog网站技巧
爬虫·数据采集·zblog·网站运营·网页数据抓取
青瓷程序设计2 小时前
【害虫识别系统】Python+深度学习+人工智能+算法模型+TensorFlow+图像识别+卷积网络算法
人工智能·python·深度学习
YuTaoShao2 小时前
【LeetCode 每日一题】3602. 十六进制和三十六进制转化——(解法二)手写进制转换
linux·python·leetcode
充值修改昵称2 小时前
数据结构基础:图论基础全面解析
数据结构·python·图论
喵手2 小时前
Python爬虫实战:城市公交数据采集实战:从多线路分页到结构化站点序列(附CSV导出 + SQLite持久化存储)!
爬虫·python·爬虫实战·零基础python爬虫教学·城市交通数据采集·多线路分页导出csv·sqlite持久化存储
2301_811232982 小时前
使用Python进行PDF文件的处理与操作
jvm·数据库·python
深蓝海拓2 小时前
海康 MV 相机几种Bayer RG像素格式的处理
笔记·python·qt·学习·pyqt
molaifeng2 小时前
从 Stdio 到 HTTP:用 Go 打造按需加载的 SQLite MCP Server
http·golang·sqlite·mcp