nick-choudhary/gmaps-scraper
GitHub: nick-choudhary/gmaps-scraper
无需 API 密钥的异步 Google Maps 商家数据爬虫,通过逆向内部 protobuf 端点并利用网格搜索突破结果限制,支持多层级数据富化与联系方式提取。
Stars: 1 | Forks: 0
# gmaps-scraper
**Google Maps 爬虫** — 一个异步 Python 库、CLI 工具和 MCP 服务器,通过纯 **HTTP** 提取结构化的 **Google Maps 商家数据**(名称、地址、电话号码、网站、评分、评论、类别、营业时间),以及用于潜在客户开发的**邮箱和社交媒体主页**。无需官方 API 密钥、无需登录 Google 账号、无需浏览器自动化。
它调用与 Google Maps 网页应用相同的内部 `pb=` protobuf 端点,通过网格 / 自适应小地图搜索突破每次视图约 120 条结果的限制,提供真实的完整性报告、可选的地点详情富化以及网站联系方式提取。适用于本地商家潜在客户开发、市场调研和数据挖掘。
**无需官方 API 密钥。无需登录 Google 账号。**
采用与 [notebooklm-py](https://github.com/teng-lin/notebooklm-py) 相同的逆向工程方法论,以及来自 [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) 的反检测模式构建。
## 三种抓取模式
该库支持三种操作模式,从轻量级到全面富化:
### 模式 1:仅阶段 1 — 搜索(默认)
```
cookie: none (scraped NID/AEC/SOCS only)
login: not required
speed: ~0.5s per search request
cost: free
fields: ~15 core fields
```
零配置的快速网格搜索。获取名称、地址、电话、网站、评分、纬度/经度、类别、时区、自治市、街区、快速设施以及作者照片。**不会获取**评论数、描述、缩略图、结构化营业时间、关于/设施、照片和热门时间。
### 模式 2:阶段 1 + 阶段 2 — 富化(无需登录)
```
cookie: scraped NID/AEC/SOCS
login: not required
speed: Phase 1 + ~0.5s per place for details
cost: free
fields: ~30 fields (all Mode 1 + enrichment)
```
在网格搜索之后,通过 `/maps/preview/place` 获取每个结果的地点详情。新增:评论数、各星级评论数、plus_code、缩略图、结构化营业时间、所有者信息。**依然缺失**:描述(编辑摘要)、照片、关于/设施、热门时间 — Google 将这些内容置于登录限制之后。
### 模式 3:阶段 1 + 阶段 2 — 使用登录 Cookie
```
cookie: full Google account cookies (SID, HSID, SSID, SAPISID, etc.)
login: required
speed: same as Mode 2
cost: free (but uses your Google account)
fields: ~40 fields (everything available)
```
全面富化。添加模式 2 缺失的所有内容:编辑描述、照片库、关于/设施部分(无障碍设施、服务选项、人群、规划)、热门时间直方图以及更详细的评论数据。
### 对比表
| 字段 | 模式 1(搜索) | 模式 2(+详情,无需登录) | 模式 3(+详情,需登录) |
|-------|:---:|:---:|:---:|
| **标识信息** | | | |
| 名称 | ✅ | ✅ | ✅ |
| place_id | ✅ | ✅ | ✅ |
| hex_id, ftid, data_id | ✅ | ✅ | ✅ |
| **联系方式** | | | |
| 电话 | ✅ | ✅ | ✅ |
| 网站 | ✅ | ✅ | ✅ |
| google_maps_url | ✅ | ✅ | ✅ |
| plus_code | — | ✅ | ✅ |
| **地址** | | | |
| 完整地址 | ✅ | ✅ | ✅ |
| 街道、城市、州、邮政编码、国家 | ✅ | ✅ | ✅ |
| 自治市、街区 | ✅ | ✅ | ✅ |
| **评分** | | | |
| 评分(星级) | ✅ | ✅ | ✅ |
| 评论数 | — | ✅ | ✅ |
| 各星级评论数(1-5 分布) | — | ✅ | ✅ |
| 评论链接 | ✅ | ✅ | ✅ |
| 价格区间、价格水平 | ✅ | ✅ | ✅ |
| **位置** | | | |
| 纬度、经度 | ✅ | ✅ | ✅ |
| **商业信息** | | | |
| 类别 | ✅ | ✅ | ✅ |
| 时区 | ✅ | ✅ | ✅ |
| 营业时间(结构化) | — | ✅ | ✅ |
| 状态(营业中/已关闭) | ✅ | ✅ | ✅ |
| 描述(编辑) | — | — | ✅ |
| 热门时间 | — | — | ✅ |
| 快速设施 | ✅ | ✅ | ✅ |
| **媒体** | | | |
| 缩略图 | — | ✅ | ✅ |
| 作者照片 | ✅ | ✅ | ✅ |
| 照片(图库) | — | — | ✅ |
| 图像 | — | — | ✅ |
| 街景链接 | — | — | ✅ |
| **设施与链接** | | | |
| 关于(无障碍设施、服务等) | — | — | ✅ |
| 信用卡 | — | — | ✅ |
| 预订 | — | — | ✅ |
| 在线下单 | — | — | ✅ |
| 菜单 | — | — | ✅ |
| 所有者 | — | ✅ | ✅ |
## 反检测特性
改编自 [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) 的模式:
- **User-Agent 轮换** — 6 个真实的浏览器 UA(Chrome/Firefox/Edge,Windows/macOS),每次请求轮换
- **抖动速率限制** — 可配置的基础延迟,带有 ±30% 的随机抖动
- **抖动指数退避** — 遇到 429/5xx 错误时,使用带抖动的延迟进行重试
- **浏览器请求头一致性** — 完整的 `Sec-Fetch-*`、`Sec-Ch-Ua`、`Upgrade-Insecure-Requests`
- **会话新鲜度跟踪** — 自动标记过期会话(>15分钟),使用 `refresh_session()` 进行循环更新
- **随机网格单元顺序** — 打乱单元搜索顺序,避免顺序化的空间模式
## 工作原理
Google Maps **没有**用于这些操作的官方公开 API。
maps.google.com 上的网页应用使用自定义的 `pb=`(protobuf 编码)URL 参数格式与 Google 服务器通信。
该库对该协议进行了逆向工程:
1. **Cookie 会话** — 通过同意流程建立有效的 NID/AEC/SOCS cookie
2. **查询编码** — 使用 `!field{type}{value}` 表示法构建 `pb=` 参数
3. **HTTP 请求** — 调用内部端点(搜索、地点详情、评论)
4. **响应解析** — 去除防 XSSI 前缀,解析深度嵌套的 JSON 数组
### 两阶段架构
```
Phase 1: Grid Search (/search?tbm=map&pb=...)
├─ Divide area into grid cells
├─ Search each cell center independently
├─ Overcomes Google's ~120 results-per-area limit
├─ Returns ~15-20 sparse fields per place
└─ Randomized cell order (anti-detection)
Phase 2: Place Detail Enrichment (/maps/preview/place?pb=...) [optional]
├─ One request per unique place_id from Phase 1
├─ Returns 30-40 enriched fields
├─ Works with or without Google login cookies
└─ Login cookies unlock: description, photos, about, popular_times
```
### 内部端点
| 功能 | 端点 | pb= 开启符号 |
|---------|----------|------------|
| 搜索 | `https://www.google.com/search?tbm=map&pb=...` | `!1s{query}!4m8!1m3!1d{viewport}...` |
| 地点详情 | `https://www.google.com/maps/preview/place?pb=...` | `!1m22!1s{hex_id}!3m12!1m3!1d{viewport}...` |
| 评论 | `https://www.google.com/maps/rpc/listugcposts?pb=...` | `!1m...` |
### 网格搜索:突破 120 条结果限制
Google Maps 将每个区域的搜索结果限制在约 120 条。为了获得全面的覆盖范围:
1. 将目标区域划分为网格单元(大小可配置,默认为 0.5km)
2. 独立搜索每个单元的中心
3. 使用稳定的 Google 标识符进行去重
4. 除非被中断或封顶,否则搜索每个计划中的单元
5. 随机化单元顺序以避免被检测
### 协议
Google Maps 在 `pb=` URL 参数中使用自定义的 `!field{type}{value}` 表示法:
```
!1m2!2scoffee shop!3d30.2672!4d-97.7431
```
→ 字段 1 = 包含 2 个子字段的消息
→ 字段 2 = 字符串 "coffee shop"
→ 字段 3 = 双精度浮点数 30.2672
→ 字段 4 = 双精度浮点数 -97.7431
**地点详情 pb 格式**(来自逆向工程的关键见解):
```
!1m22 # opener (m22, not m0!)
!1s{hex_id_with_0x} # hex_id WITH 0x prefix
!3m12!1m3!1d{viewport}!2d{lng}!3d{lat}
!2m3!1f0!2f0!3f0
!3m2!1i{width}!2i{height}!4f{zoom}
!4m2!3d{center_lat}!4d{center_lng}
!15m4!1m3!1s{hex_id}!4s{ftid}!5s{place_id}!6s{query}
...{50+ feature flags}
```
响应以带有 `)]}'` 防 XSSI 前缀和深度嵌套数组的 JSON 形式返回。
## 安装
```
pip install -e .
```
或者包含开发依赖:
```
pip install -e ".[dev]"
```
## 快速开始
### CLI
```
# 快速搜索:自然输入地点名称(无需坐标)
gmaps search "coffee shops in Austin TX"
# 从指定位置进行全面且经过 boundary 过滤的采集
gmaps collect "chiropractors" --location "Atlanta, Georgia" \
--enrich --max-contacts 20 -o atlanta-chiropractors.json
# 若中断,可从持久化 checkpoint 继续
gmaps collect "chiropractors" --location "Atlanta, Georgia" \
--enrich --max-contacts 20 -o atlanta-chiropractors.json --resume
# Mode 2:搜索 + enrichment(无需登录)
gmaps search "coffee shops in Austin TX" --enrich
# Mode 3:搜索 + enrichment(使用 login cookies)
gmaps search "coffee shops in Austin TX" --enrich --cookies cookies.json
# 高级:自行提供 boundary 和 cell size
gmaps grid "hvac" --bbox 40.4,-74.3,40.9,-73.6 --cell-size 0.5
# JSON 输出
gmaps search "hotels in Manhattan" -o results.json
# 最多对 10 个符合条件的商业网站尝试提取联系方式
gmaps search "plumbers in Austin TX" -n 50 \
--max-contacts 10 --format json -o contacts.json
```
`collect` 解析命名区域,除非被覆盖,否则选择一个网格大小,过滤掉解析边界之外的溢出结果,并写入四个文件:最终的 JSON、增量 JSONL 记录、原子检查点和运行清单。清单报告 `complete`、任何不完整的原因、已处理/失败的单元、重复项、边界拒绝以及阶段计数。结果上限或达到饱和的 120 条结果的单元将被视为完整性警告,并产生 `complete: false`。每条记录还会保留发现它的网格单元。在开始工作之前,CLI 会打印计划中的单元、大致的 Google 请求量以及已启用的阶段。
### Python API
```
import asyncio
from gmaps import GMapsClient
async def main():
# Mode 1: Phase 1 only (default)
async with GMapsClient() as client:
results = await client.search.places("coffee shops", latitude=30.2672, longitude=-97.7431)
for place in results.places:
print(f"{place.name} — {place.rating}★")
# Mode 2: Phase 1 + Phase 2 (no login)
async with GMapsClient(enrich=True) as client:
results = await client.search.places("coffee shops", latitude=30.2672, longitude=-97.7431)
for place in results.places:
print(f"{place.name} — {place.rating}★ ({place.review_count} reviews)")
# Mode 3: With raw login cookies exported from your own Google session
login_cookies = "SID=...; HSID=...; SSID=..."
async with GMapsClient(enrich=True, login_cookies=login_cookies) as client:
results = await client.search.places("coffee shops", latitude=30.2672, longitude=-97.7431)
for place in results.places:
print(f"{place.name} — {place.description}")
# Best-effort contacts from each business's own website
async with GMapsClient() as client:
results = await client.search.places("plumbers", latitude=30.2672, longitude=-97.7431)
await client.extract_contacts(results.places)
for place in results.places:
print(place.name, place.emails, place.social_links)
# Grid search
from gmaps.grid import BoundingBox
bbox = BoundingBox(min_lat=40.4, min_lon=-74.3, max_lat=40.9, max_lon=-73.6)
results = await client.search.grid_search("hvac", bbox, cell_size_km=0.5)
asyncio.run(main())
```
### 分组 JSON 输出
每个地点都会生成干净的分组 JSON:
```
place = results.places[0]
print(place.to_dict())
```
```
{
"name": "Comunidad Specialty Coffee",
"place_id": "ChIJE6IviwK1RIYRjsGXkaRQa_c",
"hex_id": "0x8644b5028b2fa213:0xf76b50a49197c18e",
"ftid": "/g/11mlg2rrdy",
"contact": {
"phone": "(512) 504-0023",
"website": "https://example.com",
"emails": ["hello@example.com"],
"social_links": {
"linkedin": "https://www.linkedin.com/company/example",
"instagram": "https://www.instagram.com/example"
},
"google_maps_url": "https://www.google.com/maps/place/?q=place_id:ChIJ...",
"plus_code": "7789+27 Austin, Texas"
},
"address": {
"full": "1008 E 6th St, Austin, TX 78702",
"street": "1008 E 6th St",
"city": "Austin",
"state": "Texas",
"postal_code": "78702",
"country": "US",
"borough": "East Austin",
"neighborhood": "East Austin"
},
"rating": {
"rating": 4.9,
"review_count": 92,
"reviews_per_rating": {"5": 89, "1": 3},
"price_range": "$"
},
"location": {"latitude": 30.265, "longitude": -97.732},
"business": {
"categories": ["Coffee shop"],
"hours": {"Wednesday": ["8AM-4PM"], ...},
"timezone": "America/Chicago",
"quick_amenities": ["Dogs allowed"]
},
"media": {
"thumbnail": "https://lh3.googleusercontent.com/...",
"author_photo": "https://lh6.googleusercontent.com/..."
}
}
```
## 邮箱和社交联系方式提取
联系方式提取通过 `--contacts` 或 `GMapsClient.extract_contacts()` 启用。
它会访问每个商家自己的网站,并提取返回页面内容中存在的联系方式。
使用 `--max-contacts N` 来限制尝试的合格商家网站数量。
这不会限制发现的 Google Maps 商家数量,也不意味着“找到 N 个邮箱”。
每个保留的记录都会收到一个联系状态;被推迟的记录使用 `not_attempted_limit`。提取的邮箱和社交主页包含在 `contact_sources` 中的来源页面。
邮箱处理包括纯文本地址、`mailto:` 链接以及 Cloudflare `data-cfemail` 解混淆。社交链接处理涵盖 LinkedIn、Facebook、Instagram、Twitter/X、YouTube、TikTok、Pinterest、WhatsApp 和 Telegram,同时过滤常见的分享/意图链接和跟踪参数。邮箱精度过滤器可滤除 URL 编码的伪影、占位符域名和不相关的自定义域名;常见的消费者邮件提供商仍然被允许。
默认的抓取器是直接 HTTP。配置后,回退链可以使用 TinyFish、Firecrawl 或代理,然后再回退到直接 HTTP:
```
export TINYFISH_API_KEY=tf-...
export FIRECRAWL_API_KEY=fc-...
gmaps grid "hvac" --bbox 40.4,-74.3,40.9,-73.6 --contacts -o leads.json
```
此功能已尽最大努力实现:它不会猜测地址、搜索外部目录或社交网络、绕过登录,也不保证每个商家都能找到联系方式。仅限 JavaScript 和受到严密保护的站点可能需要托管的抓取器。模型辅助提取只能通过 `gmaps.contacts` 中的可选 Python 适配器使用;默认情况下未启用任何托管模型提供商。
有关提供商配置,请参阅[内容抓取](docs/content-fetching.md)。
## 本地开发与验证
```
python -m venv .venv
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# macOS/Linux: source .venv/bin/activate
python -m pip install --upgrade pip
pip install -e ".[dev]"
python -m pytest tests -q
python -m ruff check src tests
python -m ruff format --check src tests
python -c "import gmaps; print(gmaps.__version__)"
gmaps --help
```
重点联系方式检查:
```
python -m pytest tests/test_website.py tests/test_contacts.py -v
```
实时冒烟测试(使用网络和 Google 当前的内部端点):
```
gmaps search "coffee" --lat 30.2672 --lng -97.7431 -n 3 -o smoke-search.json
gmaps search "plumber" --lat 30.2672 --lng -97.7431 -n 3 \
--contacts -o smoke-contacts.json
```
## 环境要求
- Python 3.10+
- `httpx` — HTTP 客户端
- `click` — CLI 框架
- `rich` — 终端格式化
- 无需 Google 账号(适用于模式 1 和 2)
## 速率限制与反检测
内置针对速率限制和 IP 封禁的保护:
| 功能 | 配置 | 默认值 |
|---------|--------|---------|
| 最小请求间隔 | `min_delay` | 1.5s |
| 抖动 | `jitter_pct` | ±30% |
| 最大重试次数 | `max_retries` | 3 |
| 重试退避 | 指数级 | 2s 基准 |
| UA 轮换池 | 固定 | 6 个 UA |
| 会话新鲜度 | `is_session_stale` | 15分钟 |
| 网格单元顺序 | 打乱 | 总是 |
对于高强度抓取,请使用住宅代理:
```
client = GMapsClient(proxy="http://residential-proxy:8080")
```
## 注意事项
- 这是一个使用逆向工程协议的**非官方**客户端
- Google 可能随时更改其内部 API 格式
- 响应字段索引可能会随着 Google Maps 更新而发生偏移
- 对于大批量抓取,建议使用代理
- 登录 Cookie 会过期 — 需定期刷新
- 请遵守 Google 的服务条款
## 项目结构
```
gmaps-scraper/
├── src/gmaps/
│ ├── __init__.py # Package entry
│ ├── client.py # Main GMapsClient
│ ├── transport.py # HTTP transport + anti-detection
│ ├── exceptions.py # Error hierarchy
│ ├── cli.py # CLI interface
│ ├── website.py # Email + social-profile extraction
│ ├── fetchers.py # TinyFish/Firecrawl/proxy/direct fallback chain
│ ├── validation.py # Parser-drift health checks
│ ├── schema.py # Swappable parser field schema
│ ├── healing.py # Opt-in deterministic schema repair
│ ├── control.py # Opt-in adaptive grid/rate controller
│ ├── identity.py # Opt-in captured browser identity replay
│ ├── contacts.py # Regex/model contact-extractor adapters
│ ├── registry.py # Provider registry
│ ├── evaluation.py # Provider evaluation harness
│ ├── _search.py # Search API + grid search
│ ├── _places.py # Places API (Phase 2 enrichment)
│ ├── _reviews.py # Reviews API
│ ├── _auth/
│ │ ├── __init__.py
│ │ └── session.py # Cookie session management
│ ├── grid.py # Grid subdivision for area coverage
│ └── rpc/
│ ├── __init__.py
│ ├── types.py # Constants, enums
│ ├── encoder.py # Request parameter encoding
│ ├── decoder.py # Response decoding (anti-XSSI)
│ ├── pb_encoder.py # pb= protobuf format encoding
│ └── parser.py # Response field extraction (58 fields)
├── scripts/ # Test scripts
├── docs/ # Protocol, fetching, and implementation guides
├── pyproject.toml
└── README.md
```
## 参考
- [notebooklm-py](https://github.com/teng-lin/notebooklm-py) — 类似的针对 NotebookLM 的逆向工程方法
- [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) — Go 爬虫、反检测模式、字段索引
- [GoogleMapsCollector](https://github.com/promisingcoder/GoogleMapsCollector) — 基于 HTTP 的 GMaps 爬虫,包含 protobuf 解码
- [SerpApi 博客](https://serpapi.com/blog/how-we-reverse-engineered-google-maps-pagination/) — 分页逆向工程深度解析
标签:MCP, Python异步, 命令控制, 商业信息提取, 数据采集, 网络协议逆向, 计算机取证, 逆向工具