google-maps-leads-scraper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """Export Google Maps businesses, place details and reviews with Python and the Scrape.do Google Maps API."""
2
+
3
+ from .client import GoogleMapsScraper, ScrapeError
4
+ from .models import PLACE_FIELDS, REVIEW_FIELDS, Place, Review, dedupe, format_hours, format_ll, parse_place, parse_reviews, parse_search
5
+
6
+ __all__ = [
7
+ "GoogleMapsScraper",
8
+ "PLACE_FIELDS",
9
+ "Place",
10
+ "REVIEW_FIELDS",
11
+ "Review",
12
+ "ScrapeError",
13
+ "dedupe",
14
+ "format_hours",
15
+ "format_ll",
16
+ "parse_place",
17
+ "parse_reviews",
18
+ "parse_search",
19
+ ]
20
+
21
+ __version__ = "0.1.0"
@@ -0,0 +1,141 @@
1
+ """Command line entry point.
2
+
3
+ google-maps-leads-scraper search "coffee shop" --ll 30.2672,-97.7431 --pages 3 -f csv -o leads.csv
4
+ google-maps-leads-scraper place ChIJ...
5
+ google-maps-leads-scraper reviews --data-id 0x...:0x... --sort newest --pages 2
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import csv
12
+ import json
13
+ import sys
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from . import __version__
17
+ from .client import SORT_OPTIONS, TOKEN_ENV, GoogleMapsScraper, ScrapeError
18
+ from .models import PLACE_FIELDS, REVIEW_FIELDS, dedupe
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog="google-maps-leads-scraper",
24
+ description="Export Google Maps businesses and reviews to JSON or CSV through the Scrape.do Google Maps API.",
25
+ )
26
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
27
+ common = argparse.ArgumentParser(add_help=False)
28
+ common.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
29
+ common.add_argument("--gl", default="us", help="Country code for results (default: us)")
30
+ common.add_argument("--hl", default="en", help="Language code (default: en)")
31
+ common.add_argument("-f", "--format", choices=("json", "csv"), default="json", help="Output format (default: json)")
32
+ common.add_argument("-o", "--output", help="Write to this file instead of stdout")
33
+
34
+ sub = parser.add_subparsers(dest="command", required=True)
35
+
36
+ search = sub.add_parser("search", parents=[common], help="Businesses for one or more queries")
37
+ search.add_argument("queries", nargs="+", help='Search terms, e.g. "dentist" or "coffee shop in Austin, TX"')
38
+ search.add_argument("--ll", action="append", default=[], help="Centre point lat,lng (repeatable for a grid)")
39
+ search.add_argument("--zoom", type=float, default=14, help="Map zoom for --ll (default: 14)")
40
+ search.add_argument("--pages", type=int, default=1, help="Pages per query and point, 20 places each, max 6 (default: 1)")
41
+ search.add_argument("--details", action="store_true", help="Also fetch each place's details (10 credits per place)")
42
+
43
+ place = sub.add_parser("place", parents=[common], help="Details for places by place_id")
44
+ place.add_argument("place_ids", nargs="*", help="Google place IDs (ChIJ...)")
45
+ place.add_argument("--cid", action="append", default=[], help="Look up by data_cid instead (repeatable)")
46
+
47
+ reviews = sub.add_parser("reviews", parents=[common], help="Reviews for one place")
48
+ target = reviews.add_mutually_exclusive_group(required=True)
49
+ target.add_argument("--data-id", help="Feature ID, e.g. 0x8644b5b055cd46fd:0x90bd2653f9c3ab4d")
50
+ target.add_argument("--place-id", help="Google place ID (ChIJ...)")
51
+ reviews.add_argument("--sort", choices=SORT_OPTIONS, default="newest", help="Review order (default: newest)")
52
+ reviews.add_argument("--pages", type=int, default=1, help="Review pages, about 10 each (default: 1)")
53
+ reviews.add_argument("--query", default="", help="Only reviews that mention this word")
54
+ return parser
55
+
56
+
57
+ def _write(rows: List[Dict[str, Any]], records: List[Dict[str, Any]], fields: List[str], fmt: str, output: Optional[str]) -> None:
58
+ stream = open(output, "w", encoding="utf-8", newline="" if fmt == "csv" else None) if output else sys.stdout
59
+ try:
60
+ if fmt == "csv":
61
+ writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
62
+ writer.writeheader()
63
+ writer.writerows(rows)
64
+ else:
65
+ json.dump(records, stream, indent=2, ensure_ascii=False)
66
+ stream.write("\n")
67
+ finally:
68
+ if output:
69
+ stream.close()
70
+
71
+
72
+ def main(argv: Optional[List[str]] = None) -> int:
73
+ args = build_parser().parse_args(argv)
74
+ try:
75
+ maps = GoogleMapsScraper(token=args.token, gl=args.gl, hl=args.hl)
76
+ except ValueError as exc:
77
+ print(exc, file=sys.stderr)
78
+ return 2
79
+
80
+ failures = 0
81
+ if args.command == "search":
82
+ places = []
83
+ for query in args.queries:
84
+ for point in args.ll or [None]:
85
+ label = f"{query!r}" + (f" @ {point}" if point else "")
86
+ try:
87
+ found = maps.search(query, ll=point, pages=args.pages, zoom=args.zoom)
88
+ except (ScrapeError, ValueError) as exc:
89
+ failures += 1
90
+ print(f"fail {label}: {exc}", file=sys.stderr)
91
+ continue
92
+ print(f"ok {label}: {len(found)} place(s)", file=sys.stderr)
93
+ places.extend(found)
94
+ places = dedupe(places)
95
+ if args.details:
96
+ detailed = []
97
+ for p in places:
98
+ try:
99
+ detailed.append(maps.place(p.place_id, p.data_cid) if (p.place_id or p.data_cid) else p)
100
+ except (ScrapeError, ValueError) as exc:
101
+ print(f"fail details {p.name}: {exc}", file=sys.stderr)
102
+ detailed.append(p)
103
+ places = detailed
104
+ _write([p.to_row() for p in places], [p.to_dict() for p in places], PLACE_FIELDS, args.format, args.output)
105
+ count = len(places)
106
+ elif args.command == "place":
107
+ if not args.place_ids and not args.cid:
108
+ print("Pass at least one place_id or --cid.", file=sys.stderr)
109
+ return 2
110
+ places = []
111
+ for pid in args.place_ids:
112
+ try:
113
+ places.append(maps.place(place_id=pid))
114
+ except (ScrapeError, ValueError) as exc:
115
+ failures += 1
116
+ print(f"fail {pid}: {exc}", file=sys.stderr)
117
+ for cid in args.cid:
118
+ try:
119
+ places.append(maps.place(data_cid=cid))
120
+ except (ScrapeError, ValueError) as exc:
121
+ failures += 1
122
+ print(f"fail cid {cid}: {exc}", file=sys.stderr)
123
+ _write([p.to_row() for p in places], [p.to_dict() for p in places], PLACE_FIELDS, args.format, args.output)
124
+ count = len(places)
125
+ else:
126
+ try:
127
+ found = maps.reviews(
128
+ data_id=args.data_id or "", place_id=args.place_id or "", sort_by=args.sort, pages=args.pages, query=args.query
129
+ )
130
+ except (ScrapeError, ValueError) as exc:
131
+ print(f"fail reviews: {exc}", file=sys.stderr)
132
+ return 1
133
+ _write([r.to_row() for r in found], [r.to_dict() for r in found], REVIEW_FIELDS, args.format, args.output)
134
+ count = len(found)
135
+
136
+ print(f"{count} record(s), {maps.credits_used} credits used" + (f", saved to {args.output}" if args.output else ""), file=sys.stderr)
137
+ return 1 if failures and not count else 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ raise SystemExit(main())
@@ -0,0 +1,203 @@
1
+ """Call the Scrape.do Google Maps API.
2
+
3
+ Google Maps renders everything with JavaScript and serves a consent wall,
4
+ CAPTCHAs and rate limits to scripts. The Scrape.do Google Maps API runs the
5
+ request from a residential IP and returns the result as parsed JSON, so there
6
+ is no HTML or protobuf to decode:
7
+
8
+ * ``/plugin/google/maps/search`` businesses for a query around a point
9
+ (20 per page, about 120 per query)
10
+ * ``/plugin/google/maps/place`` one business by ``place_id`` or ``data_cid``
11
+ * ``/plugin/google/maps/reviews`` reviews by ``data_id`` or ``place_id``
12
+
13
+ Every successful call costs 10 credits.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import time
20
+ from typing import Any, Dict, Iterator, List, Optional
21
+
22
+ import requests
23
+
24
+ from .models import Place, Review, dedupe, format_ll, next_page_token, parse_place, parse_reviews, parse_search
25
+
26
+ API_BASE = "https://api.scrape.do/plugin/google/maps"
27
+ TOKEN_ENV = "SCRAPEDO_TOKEN"
28
+ PAGE_SIZE = 20
29
+ MAX_PAGES = 6 # Google Maps stops at about 120 results per query
30
+ SORT_OPTIONS = ("most_relevant", "newest", "highest_rating", "lowest_rating")
31
+
32
+
33
+ class ScrapeError(RuntimeError):
34
+ """Raised when the API call fails."""
35
+
36
+ def __init__(self, message: str, status_code: Optional[int] = None):
37
+ super().__init__(message)
38
+ self.status_code = status_code
39
+
40
+
41
+ class GoogleMapsScraper:
42
+ """Search Google Maps, look up places and read reviews.
43
+
44
+ >>> maps = GoogleMapsScraper(token="YOUR_SCRAPEDO_TOKEN")
45
+ >>> places = maps.search("coffee shop", ll="30.2672,-97.7431", pages=2)
46
+ >>> details = maps.place(places[0].place_id)
47
+ >>> reviews = maps.reviews(data_id=places[0].data_id, sort_by="newest")
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ token: Optional[str] = None,
53
+ gl: str = "us",
54
+ hl: str = "en",
55
+ timeout: float = 90.0,
56
+ retries: int = 2,
57
+ session: Optional[requests.Session] = None,
58
+ ):
59
+ self.token = token or os.environ.get(TOKEN_ENV, "")
60
+ if not self.token:
61
+ raise ValueError(f"A Scrape.do API token is required. Pass token=... or set {TOKEN_ENV}.")
62
+ self.gl = gl
63
+ self.hl = hl
64
+ self.timeout = timeout
65
+ self.retries = max(0, retries)
66
+ self.session = session or requests.Session()
67
+ self.requests_made = 0
68
+
69
+ # -- transport -----------------------------------------------------------------
70
+
71
+ def call(self, endpoint: str, params: Dict[str, Any]) -> Dict[str, Any]:
72
+ """GET ``/plugin/google/maps/<endpoint>`` and return the decoded JSON."""
73
+ query = {k: v for k, v in params.items() if v not in (None, "")}
74
+ query["token"] = self.token
75
+ url = f"{API_BASE}/{endpoint}"
76
+ last_error: Optional[ScrapeError] = None
77
+ for attempt in range(self.retries + 1):
78
+ try:
79
+ response = self.session.get(url, params=query, timeout=self.timeout)
80
+ except requests.RequestException as exc:
81
+ last_error = ScrapeError(f"Request failed: {exc}")
82
+ else:
83
+ if response.status_code == 200:
84
+ self.requests_made += 1 # only successful calls are billed
85
+ try:
86
+ return response.json()
87
+ except ValueError:
88
+ last_error = ScrapeError("The API returned a non-JSON body", 200)
89
+ else:
90
+ last_error = ScrapeError(
91
+ f"{endpoint} failed with status {response.status_code}: {response.text[:200]}",
92
+ status_code=response.status_code,
93
+ )
94
+ # A bad token or bad parameters will not fix themselves
95
+ if 400 <= response.status_code < 500 and response.status_code != 429:
96
+ break
97
+ if attempt < self.retries:
98
+ time.sleep(2 * (attempt + 1))
99
+ assert last_error is not None
100
+ raise last_error
101
+
102
+ # -- search --------------------------------------------------------------------
103
+
104
+ def iter_search(self, query: str, ll: Optional[str] = None, pages: int = 1, zoom: float = 14) -> Iterator[Place]:
105
+ """Yield places for ``query`` page by page (20 per page, at most 6 pages).
106
+
107
+ ``ll`` centres the search: ``"30.2672,-97.7431"`` or ``"@30.2672,-97.7431,14z"``.
108
+ Without it, put the location in the query ("coffee shop in Austin, TX").
109
+ """
110
+ if not query.strip():
111
+ raise ValueError("query is empty")
112
+ center = format_ll(ll, zoom) if ll else None
113
+ seen = set()
114
+ for page in range(min(max(1, pages), MAX_PAGES)):
115
+ payload = self.call(
116
+ "search",
117
+ {"q": query, "ll": center, "gl": self.gl, "hl": self.hl, "start": page * PAGE_SIZE or None},
118
+ )
119
+ places = parse_search(payload)
120
+ fresh = [p for p in places if p.key not in seen]
121
+ for place in fresh:
122
+ seen.add(place.key)
123
+ yield place
124
+ if len(places) < PAGE_SIZE or not fresh:
125
+ break
126
+
127
+ def search(self, query: str, ll: Optional[str] = None, pages: int = 1, zoom: float = 14) -> List[Place]:
128
+ """All places for one query as a list (see :meth:`iter_search`)."""
129
+ return list(self.iter_search(query, ll=ll, pages=pages, zoom=zoom))
130
+
131
+ def search_grid(self, queries: List[str], points: List[str], pages: int = 1, zoom: float = 14) -> List[Place]:
132
+ """Run every query at every point and merge the results without duplicates.
133
+
134
+ One query tops out at about 120 places, so covering a city means
135
+ repeating it across several centre points (and related categories).
136
+ """
137
+ found: List[Place] = []
138
+ for query in queries:
139
+ for point in points or [None]:
140
+ found.extend(self.iter_search(query, ll=point, pages=pages, zoom=zoom))
141
+ return dedupe(found)
142
+
143
+ # -- place ---------------------------------------------------------------------
144
+
145
+ def place(self, place_id: str = "", data_cid: str = "") -> Place:
146
+ """Full details for one place: hours, rating breakdown, attributes and more."""
147
+ if not place_id and not data_cid:
148
+ raise ValueError("Pass place_id or data_cid")
149
+ payload = self.call("place", {"place_id": place_id or None, "data_cid": None if place_id else data_cid, "hl": self.hl})
150
+ return parse_place(payload)
151
+
152
+ # -- reviews -------------------------------------------------------------------
153
+
154
+ def iter_reviews(
155
+ self,
156
+ data_id: str = "",
157
+ place_id: str = "",
158
+ sort_by: str = "newest",
159
+ pages: int = 1,
160
+ query: str = "",
161
+ topic_id: str = "",
162
+ ) -> Iterator[Review]:
163
+ """Yield reviews page by page (about 10 per page).
164
+
165
+ ``sort_by`` is one of ``most_relevant``, ``newest``, ``highest_rating``,
166
+ ``lowest_rating``. ``query`` filters by a word; ``topic_id`` by one of the
167
+ topics Google extracted. Pass one of the two, not both.
168
+ """
169
+ if not data_id and not place_id:
170
+ raise ValueError("Pass data_id or place_id")
171
+ if sort_by not in SORT_OPTIONS:
172
+ raise ValueError(f"sort_by must be one of {', '.join(SORT_OPTIONS)}")
173
+ if query and topic_id:
174
+ raise ValueError("Pass query or topic_id, not both")
175
+ token = ""
176
+ seen = set()
177
+ for _ in range(max(1, pages)):
178
+ params = {
179
+ "data_id": data_id or None,
180
+ "place_id": None if data_id else place_id,
181
+ "sort_by": None if sort_by == "most_relevant" else sort_by,
182
+ "query": query or None,
183
+ "topic_id": topic_id or None,
184
+ "next_page_token": token or None,
185
+ "hl": self.hl,
186
+ }
187
+ payload = self.call("reviews", params)
188
+ fresh = [r for r in parse_reviews(payload, place_id=place_id, data_id=data_id) if r.review_id not in seen]
189
+ for review in fresh:
190
+ seen.add(review.review_id)
191
+ yield review
192
+ token = next_page_token(payload)
193
+ if not token or not fresh:
194
+ break
195
+
196
+ def reviews(self, data_id: str = "", place_id: str = "", sort_by: str = "newest", pages: int = 1, **kwargs: str) -> List[Review]:
197
+ """All reviews as a list (see :meth:`iter_reviews`)."""
198
+ return list(self.iter_reviews(data_id=data_id, place_id=place_id, sort_by=sort_by, pages=pages, **kwargs))
199
+
200
+ @property
201
+ def credits_used(self) -> int:
202
+ """Credits spent so far by this client (10 per call)."""
203
+ return self.requests_made * 10
@@ -0,0 +1,275 @@
1
+ """Turn Scrape.do Google Maps API responses into flat records.
2
+
3
+ The Scrape.do Google Maps API returns parsed JSON: ``local_results`` for a
4
+ search, ``place_results`` for one place and ``reviews`` for a review page. The
5
+ helpers here map those objects to dataclasses that export cleanly to CSV.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any, Dict, Iterable, List, Optional
13
+
14
+ DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
15
+
16
+ PLACE_FIELDS = [
17
+ "name", "category", "categories", "rating", "reviews", "phone", "website", "address",
18
+ "latitude", "longitude", "hours", "open_state", "place_id", "data_id", "data_cid",
19
+ "plus_code", "thumbnail", "maps_url",
20
+ ]
21
+
22
+ REVIEW_FIELDS = [
23
+ "place_id", "data_id", "review_id", "rating", "date", "iso_date", "text", "likes",
24
+ "author", "author_reviews", "local_guide", "owner_reply", "owner_reply_date", "link",
25
+ ]
26
+
27
+ _LL_RE = re.compile(r"^@?\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?)z?)?\s*$")
28
+ _SPACES = re.compile(r"[   ]")
29
+
30
+
31
+ def clean_text(value: Any) -> str:
32
+ """Normalize the narrow and non-breaking spaces Google puts in times."""
33
+ if value is None:
34
+ return ""
35
+ return _SPACES.sub(" ", str(value)).strip()
36
+
37
+
38
+ def format_ll(value: str, zoom: float = 14) -> str:
39
+ """Return the ``@lat,lng,zoomz`` form the API expects.
40
+
41
+ Accepts ``"30.2672,-97.7431"``, ``"30.2672, -97.7431, 13"`` or an already
42
+ formatted ``"@30.2672,-97.7431,14z"``.
43
+ """
44
+ match = _LL_RE.match(value or "")
45
+ if not match:
46
+ raise ValueError(f"Not a coordinate pair: {value!r} (expected 'lat,lng' or '@lat,lng,14z')")
47
+ lat, lng, z = match.groups()
48
+ if not -90 <= float(lat) <= 90 or not -180 <= float(lng) <= 180:
49
+ raise ValueError(f"Coordinates out of range: {value!r}")
50
+ zoom_str = z if z else (f"{zoom:g}")
51
+ return f"@{lat},{lng},{zoom_str}z"
52
+
53
+
54
+ def format_hours(hours: Any) -> str:
55
+ """Flatten ``operating_hours`` (dict) or ``hours`` (list of dicts) to one line.
56
+
57
+ >>> format_hours({"monday": "7 AM-2 PM", "tuesday": "Closed"})
58
+ 'Mon: 7 AM-2 PM; Tue: Closed'
59
+ """
60
+ merged: Dict[str, str] = {}
61
+ if isinstance(hours, dict):
62
+ merged = {str(k).lower(): clean_text(v) for k, v in hours.items()}
63
+ elif isinstance(hours, list):
64
+ for item in hours:
65
+ if isinstance(item, dict):
66
+ merged.update({str(k).lower(): clean_text(v) for k, v in item.items()})
67
+ else:
68
+ return ""
69
+ parts = [f"{day[:3].title()}: {merged[day]}" for day in DAYS if day in merged]
70
+ return "; ".join(parts)
71
+
72
+
73
+ def maps_url(place_id: str = "", data_cid: str = "") -> str:
74
+ """A link that opens the place in Google Maps."""
75
+ if data_cid:
76
+ return f"https://maps.google.com/?cid={data_cid}"
77
+ if place_id:
78
+ return f"https://www.google.com/maps/place/?q=place_id:{place_id}"
79
+ return ""
80
+
81
+
82
+ @dataclass
83
+ class Place:
84
+ """One business from a search result or a place lookup."""
85
+
86
+ name: str = ""
87
+ category: str = ""
88
+ categories: List[str] = field(default_factory=list)
89
+ rating: Optional[float] = None
90
+ reviews: Optional[int] = None
91
+ phone: str = ""
92
+ website: str = ""
93
+ address: str = ""
94
+ latitude: Optional[float] = None
95
+ longitude: Optional[float] = None
96
+ hours: str = ""
97
+ open_state: str = ""
98
+ place_id: str = ""
99
+ data_id: str = ""
100
+ data_cid: str = ""
101
+ plus_code: str = ""
102
+ thumbnail: str = ""
103
+ maps_url: str = ""
104
+ description: str = ""
105
+ rating_breakdown: Dict[str, int] = field(default_factory=dict)
106
+ attributes: Dict[str, List[str]] = field(default_factory=dict)
107
+
108
+ @classmethod
109
+ def from_api(cls, item: Dict[str, Any]) -> "Place":
110
+ coords = item.get("gps_coordinates") or {}
111
+ types = [clean_text(t) for t in (item.get("types") or []) if t]
112
+ category = clean_text(item.get("type")) or (types[0] if types else "")
113
+ if category and category not in types:
114
+ types.insert(0, category)
115
+ hours = format_hours(item.get("operating_hours") or item.get("hours"))
116
+ attributes: Dict[str, List[str]] = {}
117
+ for ext in item.get("extensions") or []:
118
+ if isinstance(ext, dict):
119
+ for key, values in ext.items():
120
+ if isinstance(values, list):
121
+ attributes[str(key)] = [clean_text(v) for v in values]
122
+ breakdown = {
123
+ str(row.get("stars")): int(row.get("amount") or 0)
124
+ for row in item.get("rating_summary") or []
125
+ if isinstance(row, dict) and row.get("stars") is not None
126
+ }
127
+ description = item.get("description")
128
+ if isinstance(description, dict):
129
+ description = description.get("snippet") or ""
130
+ place_id = str(item.get("place_id") or "")
131
+ data_cid = str(item.get("data_cid") or "")
132
+ return cls(
133
+ name=clean_text(item.get("title")),
134
+ category=category,
135
+ categories=types,
136
+ rating=_to_float(item.get("rating")),
137
+ reviews=_to_int(item.get("reviews")),
138
+ phone=clean_text(item.get("phone")),
139
+ website=clean_text(item.get("website")),
140
+ address=clean_text(item.get("address")),
141
+ latitude=_to_float(coords.get("latitude")),
142
+ longitude=_to_float(coords.get("longitude")),
143
+ hours=hours,
144
+ open_state=clean_text(item.get("open_state") or (item.get("hours") if isinstance(item.get("hours"), str) else "")),
145
+ place_id=place_id,
146
+ data_id=str(item.get("data_id") or ""),
147
+ data_cid=data_cid,
148
+ plus_code=clean_text(item.get("plus_code")),
149
+ thumbnail=clean_text(item.get("thumbnail")),
150
+ maps_url=maps_url(place_id, data_cid),
151
+ description=clean_text(description),
152
+ rating_breakdown=breakdown,
153
+ attributes=attributes,
154
+ )
155
+
156
+ @property
157
+ def key(self) -> str:
158
+ """Stable identity used to drop duplicates across pages and queries."""
159
+ return self.place_id or self.data_id or self.data_cid or f"{self.name}|{self.address}"
160
+
161
+ def to_dict(self) -> Dict[str, Any]:
162
+ return asdict(self)
163
+
164
+ def to_row(self) -> Dict[str, Any]:
165
+ row = {name: getattr(self, name) for name in PLACE_FIELDS}
166
+ row["categories"] = ", ".join(self.categories)
167
+ return {k: ("" if v is None else v) for k, v in row.items()}
168
+
169
+
170
+ @dataclass
171
+ class Review:
172
+ """One Google Maps review."""
173
+
174
+ review_id: str = ""
175
+ rating: Optional[int] = None
176
+ date: str = ""
177
+ iso_date: str = ""
178
+ text: str = ""
179
+ likes: int = 0
180
+ author: str = ""
181
+ author_reviews: Optional[int] = None
182
+ local_guide: bool = False
183
+ owner_reply: str = ""
184
+ owner_reply_date: str = ""
185
+ link: str = ""
186
+ place_id: str = ""
187
+ data_id: str = ""
188
+
189
+ @classmethod
190
+ def from_api(cls, item: Dict[str, Any], place_id: str = "", data_id: str = "") -> "Review":
191
+ user = item.get("user") or {}
192
+ response = item.get("response") or {}
193
+ snippet = item.get("snippet")
194
+ if not snippet:
195
+ snippet = (item.get("extracted_snippet") or {}).get("original")
196
+ reply = response.get("snippet") or (response.get("extracted_snippet") or {}).get("original")
197
+ return cls(
198
+ review_id=str(item.get("review_id") or ""),
199
+ rating=_to_int(item.get("rating")),
200
+ date=clean_text(item.get("date")),
201
+ iso_date=clean_text(item.get("iso_date")),
202
+ text=clean_text(snippet),
203
+ likes=_to_int(item.get("likes")) or 0,
204
+ author=clean_text(user.get("name")),
205
+ author_reviews=_to_int(user.get("reviews")),
206
+ local_guide=bool(user.get("local_guide")),
207
+ owner_reply=clean_text(reply),
208
+ owner_reply_date=clean_text(response.get("date")),
209
+ link=clean_text(item.get("link")),
210
+ place_id=place_id,
211
+ data_id=data_id,
212
+ )
213
+
214
+ def to_dict(self) -> Dict[str, Any]:
215
+ return asdict(self)
216
+
217
+ def to_row(self) -> Dict[str, Any]:
218
+ return {name: ("" if getattr(self, name) is None else getattr(self, name)) for name in REVIEW_FIELDS}
219
+
220
+
221
+ def parse_search(payload: Dict[str, Any]) -> List[Place]:
222
+ """Places from one search response (``local_results``)."""
223
+ results = payload.get("local_results") or []
224
+ if isinstance(results, dict): # a query that resolves to a single place
225
+ results = [results]
226
+ return [Place.from_api(item) for item in results if isinstance(item, dict)]
227
+
228
+
229
+ def parse_place(payload: Dict[str, Any]) -> Place:
230
+ """The place from a place response (``place_results``)."""
231
+ item = payload.get("place_results")
232
+ if not isinstance(item, dict):
233
+ raise ValueError("Response has no place_results")
234
+ return Place.from_api(item)
235
+
236
+
237
+ def parse_reviews(payload: Dict[str, Any], place_id: str = "", data_id: str = "") -> List[Review]:
238
+ """Reviews from one reviews response."""
239
+ return [
240
+ Review.from_api(item, place_id=place_id, data_id=data_id)
241
+ for item in payload.get("reviews") or []
242
+ if isinstance(item, dict)
243
+ ]
244
+
245
+
246
+ def next_page_token(payload: Dict[str, Any]) -> str:
247
+ return str((payload.get("pagination") or {}).get("next_page_token") or "")
248
+
249
+
250
+ def dedupe(places: Iterable[Place]) -> List[Place]:
251
+ """Keep the first occurrence of every place."""
252
+ seen = set()
253
+ out: List[Place] = []
254
+ for place in places:
255
+ if place.key in seen:
256
+ continue
257
+ seen.add(place.key)
258
+ out.append(place)
259
+ return out
260
+
261
+
262
+ def _to_float(value: Any) -> Optional[float]:
263
+ try:
264
+ return float(value) if value not in (None, "") else None
265
+ except (TypeError, ValueError):
266
+ return None
267
+
268
+
269
+ def _to_int(value: Any) -> Optional[int]:
270
+ if value in (None, ""):
271
+ return None
272
+ try:
273
+ return int(str(value).replace(",", "").split(".")[0])
274
+ except (TypeError, ValueError):
275
+ return None
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: google-maps-leads-scraper
3
+ Version: 0.1.0
4
+ Summary: Export Google Maps businesses (name, phone, website, address, rating, hours, coordinates), place details and reviews to CSV with Python and the Scrape.do Google Maps API.
5
+ Author: Buğrahan Saka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bugrahansss/google-maps-leads-scraper
8
+ Project-URL: Source, https://github.com/bugrahansss/google-maps-leads-scraper
9
+ Project-URL: Issues, https://github.com/bugrahansss/google-maps-leads-scraper/issues
10
+ Project-URL: Guide, https://scrape.do/blog/google-maps-scraping/
11
+ Keywords: google maps,google maps scraper,lead generation,web scraping,places,reviews,scrapedo
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: requests>=2.25
21
+ Dynamic: license-file
22
+
23
+ # Google Maps Leads Scraper
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/google-maps-leads-scraper)](https://pypi.org/project/google-maps-leads-scraper/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/google-maps-leads-scraper)](https://pypi.org/project/google-maps-leads-scraper/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
28
+
29
+ Turn a Google Maps search into a lead list. Give it a query and a point on the map; get back every business with its **name, phone, website, address, rating, review count, opening hours, coordinates and place ID**, as JSON or CSV. It can also pull a single place's full details (rating breakdown, plus code, attributes) and its **reviews** with owner replies.
30
+
31
+ It runs on the [Scrape.do Google Maps API](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=google-maps-leads-scraper), which returns parsed JSON, so there is no HTML, protobuf or browser automation to maintain.
32
+
33
+ ```bash
34
+ pip install google-maps-leads-scraper
35
+ export SCRAPEDO_TOKEN=your_token
36
+ google-maps-leads-scraper search "coffee shop" --ll 30.2672,-97.7431 --pages 6 -f csv -o austin-coffee.csv
37
+ ```
38
+
39
+ ```
40
+ ok 'coffee shop' @ 30.2672,-97.7431: 104 place(s)
41
+ 104 record(s), 60 credits used, saved to austin-coffee.csv
42
+ ```
43
+
44
+ ## Why not just request google.com/maps?
45
+
46
+ Google Maps is a JavaScript app. The page you get from `requests` has no business data in it, and the internal endpoints the app calls (`/search?tbm=map`, `/maps/preview/place`) answer in a nested protobuf-over-JSON array with an anti-XSSI prefix and no stable field names. Scripts that send many of them meet the consent wall, CAPTCHAs and rate limits, and the reviews endpoint most old tutorials used (`listentitiesreviews`) has been retired.
47
+
48
+ The Scrape.do Google Maps API makes the request from a residential IP and returns clean JSON:
49
+
50
+ | Endpoint | Input | Returns |
51
+ | --- | --- | --- |
52
+ | `/plugin/google/maps/search` | `q`, `ll`, `start` | 20 places per page, about 120 per query |
53
+ | `/plugin/google/maps/place` | `place_id` or `data_cid` | one place with hours, rating breakdown, attributes |
54
+ | `/plugin/google/maps/reviews` | `data_id` or `place_id`, `sort_by` | about 10 reviews per page, owner replies, topics |
55
+
56
+ Every successful call costs 10 credits, so a full page of 20 leads costs 10 credits and 1,000 leads cost about 500. The free plan has 1,000 credits a month.
57
+
58
+ ## Command line
59
+
60
+ ```bash
61
+ # One query around a point, 6 pages (the ~120 place maximum)
62
+ google-maps-leads-scraper search "plumber" --ll 40.7128,-74.0060 --pages 6 -f csv -o nyc-plumbers.csv
63
+
64
+ # The location can be in the query instead
65
+ google-maps-leads-scraper search "vegan restaurant in Lisbon" --gl pt
66
+
67
+ # Several queries across several points, merged without duplicates
68
+ google-maps-leads-scraper search "dentist" "orthodontist" \
69
+ --ll 30.2672,-97.7431 --ll 30.3072,-97.7559 --pages 2 -f csv -o dentists.csv
70
+
71
+ # Full details for places
72
+ google-maps-leads-scraper place ChIJ_UbNVbC1RIYRTavD-VMmvZA
73
+
74
+ # Reviews, newest first, two pages
75
+ google-maps-leads-scraper reviews --data-id 0x8644b5b055cd46fd:0x90bd2653f9c3ab4d --sort newest --pages 2 -f csv -o reviews.csv
76
+ ```
77
+
78
+ `--ll` takes `lat,lng`, `lat,lng,zoom` or the `@lat,lng,14z` form from a Maps URL. `search --details` also fetches every place's detail page (10 more credits per place).
79
+
80
+ ## Python
81
+
82
+ ```python
83
+ from google_maps_leads_scraper import GoogleMapsScraper
84
+
85
+ maps = GoogleMapsScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
86
+
87
+ places = maps.search("coffee shop", ll="30.2672,-97.7431", pages=3)
88
+ for p in places[:3]:
89
+ print(p.name, p.rating, p.reviews, p.phone, p.website)
90
+
91
+ details = maps.place(places[0].place_id)
92
+ print(details.hours) # Mon: 7 AM–2 PM; Tue: 7 AM–2 PM; ...
93
+ print(details.rating_breakdown) # {'1': 16, '2': 6, '3': 14, '4': 41, '5': 604}
94
+
95
+ for review in maps.reviews(data_id=places[0].data_id, sort_by="lowest_rating", pages=2):
96
+ print(review.rating, review.date, review.text[:80], "| reply:", bool(review.owner_reply))
97
+
98
+ print(maps.credits_used)
99
+ ```
100
+
101
+ To cover a whole city, run the query around several points; `search_grid` does that and drops duplicates by place ID. See [`examples/leads_grid.py`](examples/leads_grid.py).
102
+
103
+ ## Output fields
104
+
105
+ **Places:** `name`, `category`, `categories`, `rating`, `reviews`, `phone`, `website`, `address`, `latitude`, `longitude`, `hours`, `open_state`, `place_id`, `data_id`, `data_cid`, `plus_code`, `thumbnail`, `maps_url`. JSON output adds `rating_breakdown` and `attributes` (accessibility, service options and so on).
106
+
107
+ **Reviews:** `place_id`, `data_id`, `review_id`, `rating`, `date`, `iso_date`, `text`, `likes`, `author`, `author_reviews`, `local_guide`, `owner_reply`, `owner_reply_date`, `link`.
108
+
109
+ `place_id` feeds `place`; `data_id` feeds `reviews`.
110
+
111
+ ## Use it responsibly
112
+
113
+ Business listings are public, but reviews carry the names of the people who wrote them. Store only what you need, follow the privacy rules that apply to you (GDPR, CCPA), respect opt-outs before cold outreach, and read Google's terms for your use case.
114
+
115
+ ## Also available
116
+
117
+ - **No-code:** [Google Maps Leads Scraper on Apify](https://apify.com/scrapedo/google-maps-leads-scraper)
118
+ - **Browser demo:** [Hugging Face Space](https://huggingface.co/spaces/bugrahans/google-maps-leads-scraper)
119
+ - **Full guide:** [Google Maps Scraper: How to Scrape Google Maps for Leads](https://scrape.do/blog/google-maps-scraping/?utm_source=github&utm_medium=readme&utm_campaign=google-maps-leads-scraper), including the raw Python approach without the API
120
+
121
+ ## Tests
122
+
123
+ ```bash
124
+ python -m unittest discover -s tests
125
+ ```
126
+
127
+ The fixtures are made-up businesses on example.com; the tests never hit the network.
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,10 @@
1
+ google_maps_leads_scraper/__init__.py,sha256=wnxQvE49T74D4LJxrz6P241-05LbYCRGJ1-48mKpURw,561
2
+ google_maps_leads_scraper/cli.py,sha256=8NXEj4WG0GZ7KWQnzKpGcYznOJe-0B9SVri-UGl7Wy4,6767
3
+ google_maps_leads_scraper/client.py,sha256=BeCqwrYkfrM2hjXE3MwB4vD6rB35GJRL7TgeUSLpsPg,8690
4
+ google_maps_leads_scraper/models.py,sha256=orV5oFxvq4Lp7j1XhPXI-7zYYFzDYJfKsxiUGKJM5Yc,10046
5
+ google_maps_leads_scraper-0.1.0.dist-info/licenses/LICENSE,sha256=ROg_cpaduSqtDgeddNL4Csjnu7TkWQsINx0OuPmUTwM,1071
6
+ google_maps_leads_scraper-0.1.0.dist-info/METADATA,sha256=q8vizKzllV-C1BO8ObAV8tDfIIS3xqBC6SqK7_AjWH8,6790
7
+ google_maps_leads_scraper-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ google_maps_leads_scraper-0.1.0.dist-info/entry_points.txt,sha256=z_TxxeTmkjpQfUgHlPYzsE2KjiaE8gq_TYZz4uxvjTg,81
9
+ google_maps_leads_scraper-0.1.0.dist-info/top_level.txt,sha256=zOqylLqJi8rIqR00ewTj-nhXXb6pKjKDm2f4w5Dwpsk,26
10
+ google_maps_leads_scraper-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ google-maps-leads-scraper = google_maps_leads_scraper.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buğrahan Saka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ google_maps_leads_scraper