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异步, 命令控制, 商业信息提取, 数据采集, 网络协议逆向, 计算机取证, 逆向工具