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.
- google_maps_leads_scraper/__init__.py +21 -0
- google_maps_leads_scraper/cli.py +141 -0
- google_maps_leads_scraper/client.py +203 -0
- google_maps_leads_scraper/models.py +275 -0
- google_maps_leads_scraper-0.1.0.dist-info/METADATA +131 -0
- google_maps_leads_scraper-0.1.0.dist-info/RECORD +10 -0
- google_maps_leads_scraper-0.1.0.dist-info/WHEEL +5 -0
- google_maps_leads_scraper-0.1.0.dist-info/entry_points.txt +2 -0
- google_maps_leads_scraper-0.1.0.dist-info/licenses/LICENSE +21 -0
- google_maps_leads_scraper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
[](https://pypi.org/project/google-maps-leads-scraper/)
|
|
26
|
+
[](https://pypi.org/project/google-maps-leads-scraper/)
|
|
27
|
+
[](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,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
|