㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 标题 + 摘要](#1️⃣ 标题 + 摘要)
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
- [3️⃣ 合规与注意事项(必读)](#3️⃣ 合规与注意事项(必读))
- [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
-
- [API vs 爬虫 vs 混合方案](#API vs 爬虫 vs 混合方案)
- 整体流程架构
- 为什么选这套技术栈?
- [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
-
- Python版本要求
- 依赖安装
- 项目目录结构
- [获取高德API Key](#获取高德API Key)
- [6️⃣ 核心实现:高德API调用模块](#6️⃣ 核心实现:高德API调用模块)
- [7️⃣ 核心实现:网页补充爬取模块](#7️⃣ 核心实现:网页补充爬取模块)
- [8️⃣ 核心实现:地理工具模块](#8️⃣ 核心实现:地理工具模块)
- [9️⃣ 数据存储与管理](#9️⃣ 数据存储与管理)
- [🔟 地图可视化实现](#🔟 地图可视化实现)
-
- 使用Folium生成交互式地图
- 代码详解
-
- [1. Folium的坐标格式](#1. Folium的坐标格式)
- [2. 标记聚合的必要性](#2. 标记聚合的必要性)
- [3. HTML弹窗的自定义](#3. HTML弹窗的自定义)
- [1️⃣1️⃣ 完整主程序实现](#1️⃣1️⃣ 完整主程序实现)
- [1️⃣2️⃣ 运行方式与结果展示](#1️⃣2️⃣ 运行方式与结果展示)
- [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
- 访问 高德开放平台
- 注册并登录账号
- 进入"应用管理" → "我的应用" → "创建新应用"
- 添加Key(选择"Web服务"类型)
- 复制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)
🔗 在线文档:
- 高德开放平台文档: https://lbs.amap.com/api/webservice/guide/api/search
- Scrapy官方文档: https://docs.scrapy.org
- Folium文档: https://python-visualization.github.io/folium
- pandas文档: https://pandas.pydata.org/docs
🎥 视频教程:
- B站"数据分析那些事"频道
- Coursera"Python数据科学"课程
- YouTube"Corey Schafer"Python系列
⚖️ 法律合规:
- 《网络安全法》相关条款
- 《个人信息保护法》
- 各平台的robots.txt协议
最后的话
数据采集不仅是技术活,更是细心活。从API调用到网页解析,从数据清洗到可视化,每一步都需要考虑健壮性、合规性和可维护性。
核心原则:
- 合规第一:遵守法律法规,尊重robots.txt
- 质量优先:数据准确性比数量更重要
- 用户友好:清晰的日志,友好的错误提示
- 持续优化:根据实际情况调整策略
希望这篇教程能帮你建立完整的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 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!