google-maps-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.
- gmaps_scraper/__init__.py +33 -0
- gmaps_scraper/batch.py +263 -0
- gmaps_scraper/cli.py +238 -0
- gmaps_scraper/models.py +82 -0
- gmaps_scraper/parser.py +546 -0
- gmaps_scraper/scraper.py +494 -0
- gmaps_scraper/utils.py +205 -0
- google_maps_scraper-0.1.0.dist-info/METADATA +284 -0
- google_maps_scraper-0.1.0.dist-info/RECORD +12 -0
- google_maps_scraper-0.1.0.dist-info/WHEEL +4 -0
- google_maps_scraper-0.1.0.dist-info/entry_points.txt +2 -0
- google_maps_scraper-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Google Maps Reviews Scraper - Scrape place details without API key.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
# Async API
|
|
5
|
+
async with GoogleMapsScraper() as scraper:
|
|
6
|
+
result = await scraper.scrape("https://www.google.com/maps/search/...")
|
|
7
|
+
print(result.place.name, result.place.rating)
|
|
8
|
+
|
|
9
|
+
# Sync convenience
|
|
10
|
+
from gmaps_scraper import scrape_place
|
|
11
|
+
result = scrape_place("https://www.google.com/maps/search/...")
|
|
12
|
+
|
|
13
|
+
# Batch processing
|
|
14
|
+
from gmaps_scraper import scrape_batch, ScrapeConfig
|
|
15
|
+
config = ScrapeConfig(concurrency=5)
|
|
16
|
+
results = asyncio.run(scrape_batch(urls, config))
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from gmaps_scraper.batch import scrape_batch
|
|
20
|
+
from gmaps_scraper.models import PlaceDetails, Review, ScrapeConfig, ScrapeResult
|
|
21
|
+
from gmaps_scraper.scraper import GoogleMapsScraper, scrape_place
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"GoogleMapsScraper",
|
|
27
|
+
"scrape_place",
|
|
28
|
+
"scrape_batch",
|
|
29
|
+
"PlaceDetails",
|
|
30
|
+
"Review",
|
|
31
|
+
"ScrapeConfig",
|
|
32
|
+
"ScrapeResult",
|
|
33
|
+
]
|
gmaps_scraper/batch.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Async batch processor for high-throughput Google Maps scraping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import random
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
from gmaps_scraper.models import ScrapeConfig, ScrapeResult
|
|
15
|
+
from gmaps_scraper.scraper import GoogleMapsScraper
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("gmaps_scraper")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def scrape_batch(
|
|
21
|
+
urls: list[str],
|
|
22
|
+
config: ScrapeConfig | None = None,
|
|
23
|
+
output_path: str | Path | None = None,
|
|
24
|
+
on_result: Optional[Callable[[ScrapeResult, int, int], None]] = None,
|
|
25
|
+
resume: bool = True,
|
|
26
|
+
) -> list[ScrapeResult]:
|
|
27
|
+
"""Scrape a batch of Google Maps URLs concurrently.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
urls: List of Google Maps URLs to scrape.
|
|
31
|
+
config: Scraper configuration (concurrency, delays, etc.).
|
|
32
|
+
output_path: Path to save results incrementally (JSON or CSV).
|
|
33
|
+
on_result: Callback called after each result (result, index, total).
|
|
34
|
+
resume: If True, skip URLs already present in the output file.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of ScrapeResult objects.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> import asyncio
|
|
41
|
+
>>> from gmaps_scraper import scrape_batch, ScrapeConfig
|
|
42
|
+
>>> urls = ["https://www.google.com/maps/search/...", ...]
|
|
43
|
+
>>> config = ScrapeConfig(concurrency=5, headless=True)
|
|
44
|
+
>>> results = asyncio.run(scrape_batch(urls, config))
|
|
45
|
+
"""
|
|
46
|
+
config = config or ScrapeConfig()
|
|
47
|
+
output_path = Path(output_path) if output_path else None
|
|
48
|
+
|
|
49
|
+
# Load already scraped URLs for resume
|
|
50
|
+
completed_urls: set[str] = set()
|
|
51
|
+
existing_results: list[ScrapeResult] = []
|
|
52
|
+
if resume and output_path and output_path.exists():
|
|
53
|
+
existing_results, completed_urls = _load_existing_results(output_path)
|
|
54
|
+
if completed_urls:
|
|
55
|
+
logger.info(
|
|
56
|
+
"Resuming: %d URLs already scraped, %d remaining",
|
|
57
|
+
len(completed_urls),
|
|
58
|
+
len(urls) - len(completed_urls),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Filter out already completed URLs
|
|
62
|
+
remaining_urls = [u for u in urls if u.strip() not in completed_urls]
|
|
63
|
+
total = len(urls)
|
|
64
|
+
completed_count = len(completed_urls)
|
|
65
|
+
|
|
66
|
+
if not remaining_urls:
|
|
67
|
+
logger.info("All %d URLs already scraped!", total)
|
|
68
|
+
return existing_results
|
|
69
|
+
|
|
70
|
+
results: list[ScrapeResult] = list(existing_results)
|
|
71
|
+
semaphore = asyncio.Semaphore(config.concurrency)
|
|
72
|
+
save_lock = asyncio.Lock()
|
|
73
|
+
|
|
74
|
+
async with GoogleMapsScraper(config) as scraper:
|
|
75
|
+
|
|
76
|
+
async def _scrape_one(url: str) -> ScrapeResult:
|
|
77
|
+
nonlocal completed_count
|
|
78
|
+
async with semaphore:
|
|
79
|
+
# Random delay to avoid rate limiting
|
|
80
|
+
delay = random.uniform(config.delay_min, config.delay_max)
|
|
81
|
+
await asyncio.sleep(delay)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = await scraper.scrape(url)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
result = ScrapeResult(
|
|
87
|
+
input_url=url,
|
|
88
|
+
success=False,
|
|
89
|
+
error=str(e),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
completed_count += 1
|
|
93
|
+
|
|
94
|
+
# Callback
|
|
95
|
+
if on_result:
|
|
96
|
+
try:
|
|
97
|
+
on_result(result, completed_count, total)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Periodic save
|
|
102
|
+
async with save_lock:
|
|
103
|
+
results.append(result)
|
|
104
|
+
if (
|
|
105
|
+
output_path
|
|
106
|
+
and completed_count % config.save_interval == 0
|
|
107
|
+
):
|
|
108
|
+
_save_results(results, output_path)
|
|
109
|
+
logger.info(
|
|
110
|
+
"Progress saved: %d/%d (%.1f%%)",
|
|
111
|
+
completed_count,
|
|
112
|
+
total,
|
|
113
|
+
completed_count / total * 100,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
# Run all tasks with concurrency control
|
|
119
|
+
tasks = [_scrape_one(url) for url in remaining_urls]
|
|
120
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
121
|
+
|
|
122
|
+
# Final save
|
|
123
|
+
if output_path:
|
|
124
|
+
_save_results(results, output_path)
|
|
125
|
+
logger.info("Final results saved to %s", output_path)
|
|
126
|
+
|
|
127
|
+
return results
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_existing_results(
|
|
131
|
+
path: Path,
|
|
132
|
+
) -> tuple[list[ScrapeResult], set[str]]:
|
|
133
|
+
"""Load existing results for resume capability."""
|
|
134
|
+
results: list[ScrapeResult] = []
|
|
135
|
+
urls: set[str] = set()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
if path.suffix == ".json":
|
|
139
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
140
|
+
data = json.load(f)
|
|
141
|
+
for item in data:
|
|
142
|
+
result = ScrapeResult(**item)
|
|
143
|
+
results.append(result)
|
|
144
|
+
urls.add(result.input_url)
|
|
145
|
+
elif path.suffix == ".csv":
|
|
146
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
147
|
+
reader = csv.DictReader(f)
|
|
148
|
+
for row in reader:
|
|
149
|
+
urls.add(row.get("input_url", ""))
|
|
150
|
+
# For CSV resume, we just track URLs, not full reconstruction
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.warning("Failed to load existing results from %s: %s", path, e)
|
|
153
|
+
|
|
154
|
+
return results, urls
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _save_results(results: list[ScrapeResult], path: Path) -> None:
|
|
158
|
+
"""Save results to JSON or CSV file."""
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
|
|
161
|
+
if path.suffix == ".csv":
|
|
162
|
+
_save_csv(results, path)
|
|
163
|
+
else:
|
|
164
|
+
_save_json(results, path)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _save_json(results: list[ScrapeResult], path: Path) -> None:
|
|
168
|
+
"""Save results as JSON."""
|
|
169
|
+
data = []
|
|
170
|
+
for r in results:
|
|
171
|
+
item = r.model_dump(mode="json")
|
|
172
|
+
data.append(item)
|
|
173
|
+
|
|
174
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
175
|
+
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _save_csv(results: list[ScrapeResult], path: Path) -> None:
|
|
179
|
+
"""Save results as flat CSV (place details only, no reviews)."""
|
|
180
|
+
if not results:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
fieldnames = [
|
|
184
|
+
"input_url",
|
|
185
|
+
"success",
|
|
186
|
+
"error",
|
|
187
|
+
"scraped_at",
|
|
188
|
+
"name",
|
|
189
|
+
"place_id",
|
|
190
|
+
"address",
|
|
191
|
+
"rating",
|
|
192
|
+
"review_count",
|
|
193
|
+
"phone",
|
|
194
|
+
"website",
|
|
195
|
+
"category",
|
|
196
|
+
"price_level",
|
|
197
|
+
"latitude",
|
|
198
|
+
"longitude",
|
|
199
|
+
"plus_code",
|
|
200
|
+
"url",
|
|
201
|
+
"google_maps_url",
|
|
202
|
+
"description",
|
|
203
|
+
"photos_count",
|
|
204
|
+
"permanently_closed",
|
|
205
|
+
"temporarily_closed",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
209
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
|
210
|
+
writer.writeheader()
|
|
211
|
+
|
|
212
|
+
for r in results:
|
|
213
|
+
row = {
|
|
214
|
+
"input_url": r.input_url,
|
|
215
|
+
"success": r.success,
|
|
216
|
+
"error": r.error,
|
|
217
|
+
"scraped_at": str(r.scraped_at),
|
|
218
|
+
}
|
|
219
|
+
if r.place:
|
|
220
|
+
place_dict = r.place.model_dump()
|
|
221
|
+
# Flatten hours to a single string
|
|
222
|
+
if place_dict.get("hours"):
|
|
223
|
+
place_dict["hours"] = " | ".join(place_dict["hours"])
|
|
224
|
+
row.update(place_dict)
|
|
225
|
+
writer.writerow(row)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_urls_from_file(path: str | Path) -> list[str]:
|
|
229
|
+
"""Load URLs from a text file or CSV file.
|
|
230
|
+
|
|
231
|
+
Supports:
|
|
232
|
+
- Plain text file (one URL per line)
|
|
233
|
+
- CSV file (looks for 'url' column, or uses first column)
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
path: Path to the input file.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of URL strings.
|
|
240
|
+
"""
|
|
241
|
+
path = Path(path)
|
|
242
|
+
|
|
243
|
+
if path.suffix == ".csv":
|
|
244
|
+
urls = []
|
|
245
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
246
|
+
reader = csv.DictReader(f)
|
|
247
|
+
# Try 'url' column first, then first column
|
|
248
|
+
for row in reader:
|
|
249
|
+
url = row.get("url") or row.get("URL") or row.get("link")
|
|
250
|
+
if not url:
|
|
251
|
+
# Use first column value
|
|
252
|
+
url = next(iter(row.values()), None)
|
|
253
|
+
if url and url.strip():
|
|
254
|
+
urls.append(url.strip())
|
|
255
|
+
return urls
|
|
256
|
+
else:
|
|
257
|
+
# Plain text, one URL per line
|
|
258
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
259
|
+
return [
|
|
260
|
+
line.strip()
|
|
261
|
+
for line in f
|
|
262
|
+
if line.strip() and not line.startswith("#")
|
|
263
|
+
]
|
gmaps_scraper/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""CLI entry point for gmaps-scraper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from gmaps_scraper.batch import load_urls_from_file, scrape_batch
|
|
13
|
+
from gmaps_scraper.models import ScrapeConfig, ScrapeResult
|
|
14
|
+
from gmaps_scraper.scraper import GoogleMapsScraper
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
"""Main CLI entry point."""
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="gmaps-scraper",
|
|
21
|
+
description="Scrape Google Maps place details without API key",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-v", "--verbose",
|
|
25
|
+
action="store_true",
|
|
26
|
+
help="Enable verbose logging",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
|
|
31
|
+
# --- scrape command ---
|
|
32
|
+
scrape_parser = subparsers.add_parser(
|
|
33
|
+
"scrape",
|
|
34
|
+
help="Scrape a single Google Maps URL",
|
|
35
|
+
)
|
|
36
|
+
scrape_parser.add_argument(
|
|
37
|
+
"url",
|
|
38
|
+
help="Google Maps URL to scrape",
|
|
39
|
+
)
|
|
40
|
+
scrape_parser.add_argument(
|
|
41
|
+
"--lang",
|
|
42
|
+
default=None,
|
|
43
|
+
help="Language code (e.g., zh-TW, en, ja). Default: none",
|
|
44
|
+
)
|
|
45
|
+
scrape_parser.add_argument(
|
|
46
|
+
"--no-headless",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Show browser window (for debugging)",
|
|
49
|
+
)
|
|
50
|
+
scrape_parser.add_argument(
|
|
51
|
+
"--reviews",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Also scrape individual reviews",
|
|
54
|
+
)
|
|
55
|
+
scrape_parser.add_argument(
|
|
56
|
+
"--max-reviews",
|
|
57
|
+
type=int,
|
|
58
|
+
default=20,
|
|
59
|
+
help="Max reviews to scrape (default: 20)",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# --- batch command ---
|
|
63
|
+
batch_parser = subparsers.add_parser(
|
|
64
|
+
"batch",
|
|
65
|
+
help="Batch scrape URLs from a file",
|
|
66
|
+
)
|
|
67
|
+
batch_parser.add_argument(
|
|
68
|
+
"input",
|
|
69
|
+
help="Input file (CSV or text, one URL per line)",
|
|
70
|
+
)
|
|
71
|
+
batch_parser.add_argument(
|
|
72
|
+
"-o", "--output",
|
|
73
|
+
required=True,
|
|
74
|
+
help="Output file path (.json or .csv)",
|
|
75
|
+
)
|
|
76
|
+
batch_parser.add_argument(
|
|
77
|
+
"--concurrency",
|
|
78
|
+
type=int,
|
|
79
|
+
default=5,
|
|
80
|
+
help="Number of concurrent scrapers (default: 5)",
|
|
81
|
+
)
|
|
82
|
+
batch_parser.add_argument(
|
|
83
|
+
"--lang",
|
|
84
|
+
default=None,
|
|
85
|
+
help="Language code (e.g., zh-TW, en, ja). Default: none",
|
|
86
|
+
)
|
|
87
|
+
batch_parser.add_argument(
|
|
88
|
+
"--no-headless",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="Show browser window (for debugging)",
|
|
91
|
+
)
|
|
92
|
+
batch_parser.add_argument(
|
|
93
|
+
"--proxy",
|
|
94
|
+
help="Proxy server URL (e.g., http://proxy:8080)",
|
|
95
|
+
)
|
|
96
|
+
batch_parser.add_argument(
|
|
97
|
+
"--delay-min",
|
|
98
|
+
type=float,
|
|
99
|
+
default=2.0,
|
|
100
|
+
help="Minimum delay between requests in seconds (default: 2.0)",
|
|
101
|
+
)
|
|
102
|
+
batch_parser.add_argument(
|
|
103
|
+
"--delay-max",
|
|
104
|
+
type=float,
|
|
105
|
+
default=5.0,
|
|
106
|
+
help="Maximum delay between requests in seconds (default: 5.0)",
|
|
107
|
+
)
|
|
108
|
+
batch_parser.add_argument(
|
|
109
|
+
"--no-resume",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="Don't resume from existing output file",
|
|
112
|
+
)
|
|
113
|
+
batch_parser.add_argument(
|
|
114
|
+
"--reviews",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help="Also scrape individual reviews",
|
|
117
|
+
)
|
|
118
|
+
batch_parser.add_argument(
|
|
119
|
+
"--max-reviews",
|
|
120
|
+
type=int,
|
|
121
|
+
default=20,
|
|
122
|
+
help="Max reviews per place (default: 20)",
|
|
123
|
+
)
|
|
124
|
+
batch_parser.add_argument(
|
|
125
|
+
"--save-interval",
|
|
126
|
+
type=int,
|
|
127
|
+
default=50,
|
|
128
|
+
help="Save results every N items (default: 50)",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
args = parser.parse_args()
|
|
132
|
+
|
|
133
|
+
# Setup logging
|
|
134
|
+
log_level = logging.DEBUG if args.verbose else logging.INFO
|
|
135
|
+
logging.basicConfig(
|
|
136
|
+
level=log_level,
|
|
137
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
138
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if args.command == "scrape":
|
|
142
|
+
_cmd_scrape(args)
|
|
143
|
+
elif args.command == "batch":
|
|
144
|
+
_cmd_batch(args)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cmd_scrape(args: argparse.Namespace) -> None:
|
|
148
|
+
"""Handle `gmaps-scraper scrape` command."""
|
|
149
|
+
config = ScrapeConfig(
|
|
150
|
+
headless=not args.no_headless,
|
|
151
|
+
language=args.lang,
|
|
152
|
+
scrape_reviews=args.reviews,
|
|
153
|
+
max_reviews=args.max_reviews,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def _run() -> ScrapeResult:
|
|
157
|
+
async with GoogleMapsScraper(config) as scraper:
|
|
158
|
+
return await scraper.scrape(args.url)
|
|
159
|
+
|
|
160
|
+
result = asyncio.run(_run())
|
|
161
|
+
|
|
162
|
+
# Output as JSON
|
|
163
|
+
output = result.model_dump(mode="json")
|
|
164
|
+
print(json.dumps(output, ensure_ascii=False, indent=2, default=str))
|
|
165
|
+
|
|
166
|
+
if not result.success:
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _cmd_batch(args: argparse.Namespace) -> None:
|
|
171
|
+
"""Handle `gmaps-scraper batch` command."""
|
|
172
|
+
input_path = Path(args.input)
|
|
173
|
+
if not input_path.exists():
|
|
174
|
+
print(f"Error: Input file not found: {input_path}", file=sys.stderr)
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
|
|
177
|
+
urls = load_urls_from_file(input_path)
|
|
178
|
+
if not urls:
|
|
179
|
+
print("Error: No URLs found in input file", file=sys.stderr)
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
|
|
182
|
+
print(f"Loaded {len(urls)} URLs from {input_path}")
|
|
183
|
+
|
|
184
|
+
config = ScrapeConfig(
|
|
185
|
+
concurrency=args.concurrency,
|
|
186
|
+
headless=not args.no_headless,
|
|
187
|
+
language=args.lang,
|
|
188
|
+
proxy=args.proxy,
|
|
189
|
+
delay_min=args.delay_min,
|
|
190
|
+
delay_max=args.delay_max,
|
|
191
|
+
save_interval=args.save_interval,
|
|
192
|
+
scrape_reviews=args.reviews,
|
|
193
|
+
max_reviews=args.max_reviews,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Progress tracking
|
|
197
|
+
try:
|
|
198
|
+
from tqdm import tqdm
|
|
199
|
+
|
|
200
|
+
pbar = tqdm(total=len(urls), desc="Scraping", unit="place")
|
|
201
|
+
|
|
202
|
+
def on_result(result: ScrapeResult, idx: int, total: int) -> None:
|
|
203
|
+
pbar.update(1)
|
|
204
|
+
status = "✓" if result.success else "✗"
|
|
205
|
+
name = result.place.name if result.place else "?"
|
|
206
|
+
pbar.set_postfix_str(f"{status} {name}")
|
|
207
|
+
|
|
208
|
+
except ImportError:
|
|
209
|
+
|
|
210
|
+
def on_result(result: ScrapeResult, idx: int, total: int) -> None:
|
|
211
|
+
status = "✓" if result.success else "✗"
|
|
212
|
+
name = result.place.name if result.place else "?"
|
|
213
|
+
print(f" [{idx}/{total}] {status} {name}")
|
|
214
|
+
|
|
215
|
+
pbar = None
|
|
216
|
+
|
|
217
|
+
results = asyncio.run(
|
|
218
|
+
scrape_batch(
|
|
219
|
+
urls=urls,
|
|
220
|
+
config=config,
|
|
221
|
+
output_path=args.output,
|
|
222
|
+
on_result=on_result,
|
|
223
|
+
resume=not args.no_resume,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if pbar:
|
|
228
|
+
pbar.close()
|
|
229
|
+
|
|
230
|
+
# Summary
|
|
231
|
+
success_count = sum(1 for r in results if r.success)
|
|
232
|
+
fail_count = len(results) - success_count
|
|
233
|
+
print(f"\nDone! {success_count} succeeded, {fail_count} failed")
|
|
234
|
+
print(f"Results saved to: {args.output}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
main()
|
gmaps_scraper/models.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Pydantic data models for Google Maps scraper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlaceDetails(BaseModel):
|
|
12
|
+
"""Structured details about a Google Maps place."""
|
|
13
|
+
|
|
14
|
+
name: Optional[str] = None
|
|
15
|
+
place_id: Optional[str] = None
|
|
16
|
+
address: Optional[str] = None
|
|
17
|
+
rating: Optional[float] = None
|
|
18
|
+
review_count: Optional[int] = None
|
|
19
|
+
phone: Optional[str] = None
|
|
20
|
+
website: Optional[str] = None
|
|
21
|
+
category: Optional[str] = None
|
|
22
|
+
price_level: Optional[str] = None
|
|
23
|
+
hours: Optional[list[str]] = None
|
|
24
|
+
latitude: Optional[float] = None
|
|
25
|
+
longitude: Optional[float] = None
|
|
26
|
+
plus_code: Optional[str] = None
|
|
27
|
+
url: Optional[str] = None
|
|
28
|
+
google_maps_url: Optional[str] = None
|
|
29
|
+
image_url: Optional[str] = None
|
|
30
|
+
description: Optional[str] = None
|
|
31
|
+
photos_count: Optional[int] = None
|
|
32
|
+
|
|
33
|
+
# Additional details
|
|
34
|
+
permanently_closed: bool = False
|
|
35
|
+
temporarily_closed: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Review(BaseModel):
|
|
39
|
+
"""A single Google Maps review."""
|
|
40
|
+
|
|
41
|
+
author: Optional[str] = None
|
|
42
|
+
rating: Optional[int] = None
|
|
43
|
+
text: Optional[str] = None
|
|
44
|
+
time: Optional[str] = None
|
|
45
|
+
language: Optional[str] = None
|
|
46
|
+
likes: Optional[int] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScrapeResult(BaseModel):
|
|
50
|
+
"""Result of scraping a single Google Maps URL."""
|
|
51
|
+
|
|
52
|
+
input_url: str
|
|
53
|
+
place: Optional[PlaceDetails] = None
|
|
54
|
+
reviews: list[Review] = Field(default_factory=list)
|
|
55
|
+
success: bool = True
|
|
56
|
+
error: Optional[str] = None
|
|
57
|
+
scraped_at: datetime = Field(default_factory=datetime.now)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ScrapeConfig(BaseModel):
|
|
61
|
+
"""Configuration for the scraper."""
|
|
62
|
+
|
|
63
|
+
concurrency: int = Field(default=5, ge=1, le=50)
|
|
64
|
+
timeout: int = Field(default=30000, description="Page load timeout in ms")
|
|
65
|
+
headless: bool = True
|
|
66
|
+
language: Optional[str] = None
|
|
67
|
+
max_retries: int = 3
|
|
68
|
+
delay_min: float = 1.0
|
|
69
|
+
delay_max: float = 3.0
|
|
70
|
+
save_interval: int = Field(
|
|
71
|
+
default=50,
|
|
72
|
+
description="Save results every N items for crash recovery",
|
|
73
|
+
)
|
|
74
|
+
proxy: Optional[str] = None
|
|
75
|
+
scrape_reviews: bool = Field(
|
|
76
|
+
default=False,
|
|
77
|
+
description="Whether to also scrape individual reviews",
|
|
78
|
+
)
|
|
79
|
+
max_reviews: int = Field(
|
|
80
|
+
default=20,
|
|
81
|
+
description="Max reviews to scrape per place (if scrape_reviews=True)",
|
|
82
|
+
)
|